commit 65f009920129af6f809f248ce3ec8142dcfc1d2a Author: erdisepta Date: Tue Jul 28 16:30:09 2026 +0700 Tugas_Akhir diff --git a/.env b/.env new file mode 100644 index 0000000..06f0664 --- /dev/null +++ b/.env @@ -0,0 +1,24 @@ +# Firebase Configuration +FIREBASE_PROJECT_ID=muning-menu-recommender +FIREBASE_STORAGE_BUCKET=muning-menu-recommender.appspot.com + +# Google Services +GOOGLE_SIGN_IN_CLIENT_ID=624527944720-2hngapf8r7d1smh8114uioii2fr4bhv3.apps.googleusercontent.com + +# Gemini AI Configuration +GEMINI_API_KEY=AIzaSyBOgaUAFoVOooo8WplbBanntZdqlC7u1bE + +# App Configuration +APP_ENV=development +DEBUG_MODE=true + +# API Configuration +API_BASE_URL=https://api.muningkofie.com +API_TIMEOUT=30000 + +# Feature Flags +ENABLE_GOOGLE_SIGN_IN=true +ENABLE_OFFLINE_MODE=true +ENABLE_PUSH_NOTIFICATIONS=true +ENABLE_ANALYTICS=true +ENABLE_CRASHLYTICS=false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9f01481 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Firebase Configuration +FIREBASE_PROJECT_ID=muning-menu-recommender +FIREBASE_STORAGE_BUCKET=muning-menu-recommender.appspot.com + +# Google Services +GOOGLE_SIGN_IN_CLIENT_ID=your_google_sign_in_client_id_here + +# Gemini AI Configuration +GEMINI_API_KEY=your_gemini_api_key_here + +# App Configuration +APP_ENV=development +DEBUG_MODE=true + +# API Configuration +API_BASE_URL=https://api.muningkofie.com +API_TIMEOUT=30000 + +# Feature Flags +ENABLE_GOOGLE_SIGN_IN=true +ENABLE_OFFLINE_MODE=true +ENABLE_PUSH_NOTIFICATIONS=true +ENABLE_ANALYTICS=true +ENABLE_CRASHLYTICS=false diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..cd5ae92 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "amoriassistantpro" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79c113f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# 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 +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# 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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..af5cda8 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# 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: "edada7c56edf4a183c1735310e123c7f923584f1" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: android + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: ios + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: linux + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: macos + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: web + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: windows + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + + # 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/Cuaca.md b/Cuaca.md new file mode 100644 index 0000000..4a57c00 --- /dev/null +++ b/Cuaca.md @@ -0,0 +1,51 @@ +No,Pertanyaan (Input Kondisi Cuaca),Opsi 1: Hujan,Opsi 2: Cerah/Terik,Opsi 3: Dingin/Adem,Opsi 4: Panas/Gerah +1,Bagaimana cuaca di luar sana sekarang?,Lagi hujan deras nih.,Cerah benderang.,"Dingin banget, adem.",Panasnya minta ampun! +2,Kondisi langit di sekitarmu gimana?,Lagi mendung & hujan.,Biru bersih & cerah.,Kelabu & terasa dingin.,Silau & panas menyengat. +3,Apa yang kamu lihat dari jendela kafe?,Air hujan turun terus.,Sinar matahari terik.,Kabut/angin dingin.,Aspal menguap karena panas. +4,Butuh menu yang sesuai cuaca apa?,Yang pas buat musim hujan.,Yang cocok buat hari cerah.,Yang nemenin hawa dingin.,Yang nurunin suhu panas. +5,"Gimana ""vibes"" cuaca hari ini?",Wet & Rainy Vibes.,Sunny & Bright Vibes.,Chilly & Cool Vibes.,Hot & Burning Vibes. +6,Udara di tempatmu sekarang terasa...,Basah dan lembap.,Hangat dan terang.,Sejuk dan menggigil.,Kering dan gerah. +7,"Kalau melirik ke luar, lagi ada apa?",Ada rintik-rintik hujan.,Ada matahari yang kuat.,Ada angin sepoi dingin.,Ada hawa panas bumi. +8,Suasana di luar sedang mendukung...,Tidur karena hujan.,Jalan-jalan karena cerah.,Pakai jaket karena dingin.,Nyari AC karena panas. +9,Lagi musim apa di daerahmu hari ini?,Musim hujan/basah.,Musim kemarau/terik.,Musim dingin/berangin.,Musim panas/gerah. +10,Apa warna dominan langit sekarang?,Kelabu gelap (Hujan).,Biru muda (Cerah).,Putih pucat (Dingin).,Kuning oranye (Panas). +11,Gimana tekanan udara yang kamu rasain?,Berat karena mau hujan.,Ringan dan cerah.,Segar dan dingin.,Pengap dan panas. +12,"Seberapa butuh kamu menu ""penyelamat""?",Butuh penghangat hujan.,Butuh penyegar cerah.,Butuh penenang dingin.,Butuh pendingin panas. +13,Apa kata pertama yang muncul pas keluar?,"""Yah, hujan...""","""Wah, cerah!""","""Duh, dingin.""","""Waduh, panas!""" +14,Cuaca sekarang bikin kamu ingin...,Ngopi hangat (Hujan).,Minum jus (Cerah).,Cokelat panas (Dingin).,Es kopi banyak (Panas). +15,Gimana intensitas matahari saat ini?,Tertutup awan hujan.,Sangat kuat & cerah.,Lemah & terasa dingin.,Sangat menyengat/panas. +16,Pakaian yang kamu pakai cocok buat...,Neduh karena hujan.,OOTD karena cerah.,Hangat-hangatan (Dingin).,Baju tipis karena panas. +17,Kondisi aspal di luar sana lagi...,Basah kuyup.,Kering kerontang.,Lembap dingin.,Panas berasap. +18,Kamu merasa butuh menu yang...,Menghangatkan (Hujan).,Menyegarkan (Cerah).,Menenangkan (Dingin).,Mendinginkan (Panas). +19,Seberapa ekstrem cuaca sekarang?,Hujan gak berhenti.,Cerah nggak ada awan.,Dingin menusuk tulang.,Panas membakar kulit. +20,"Apa ""backsound"" alam sekarang?",Suara gemercik hujan.,Suara burung (Cerah).,Suara angin (Dingin).,Suara kipas/AC (Panas). +21,Lagi terjebak di kondisi cuaca apa?,Terjebak hujan.,Terjebak terik matahari.,Terjebak suhu dingin.,Terjebak hawa gerah. +22,Apa yang paling terasa di kulitmu?,Air dan kelembapan.,Sinar matahari hangat.,Angin yang sejuk.,Keringat karena panas. +23,Gimana ramalan cuaca versi matamu?,Bakal hujan lama.,Bakal cerah seharian.,Bakal tetap dingin.,Bakal tambah panas. +24,Tingkat kebutuhan es vs hangat?,100% Hangat (Hujan).,50/50 (Cerah).,80% Hangat (Dingin).,100% Es (Panas). +25,Apa gangguan terbesar di jalan tadi?,Jalanan banjir/becek.,Silau matahari.,Angin kencang dingin.,Debu dan panas. +26,Kondisi jendela kafe sekarang...,Berembun karena hujan.,Tembus cahaya cerah.,Dingin saat disentuh.,Terasa hangat kena panas. +27,Cuaca ini bikin kamu mikir tentang...,Selimut (Hujan).,Pantai (Cerah).,Pelukan (Dingin).,Es krim (Panas). +28,Suara petir atau suara burung?,Petir (Hujan).,Burung berkicau (Cerah).,Angin berdesir (Dingin).,Jangkrik siang (Panas). +29,Kamu sampai sini dalam keadaan...,Sedikit basah (Hujan).,Rapi karena cerah.,Menggigil (Dingin).,Berkeringat (Panas). +30,Seberapa terang duniamu saat ini?,Redup (Hujan).,Sangat terang (Cerah).,Abu-abu (Dingin).,Menyilaukan (Panas). +31,Cuaca sekarang paling enak buat...,Melamun (Hujan).,Ngobrol (Cerah).,Baca buku (Dingin).,Minum es (Panas). +32,Apa warna payung yang kamu butuh?,Payung hujan.,Payung peneduh terik.,"Gak butuh, butuh jaket.","Gak butuh, butuh kipas." +33,Hawa ruangan dibanding luar gimana?,Luar lebih basah.,Luar lebih cerah.,Luar jauh lebih dingin.,Luar jauh lebih panas. +34,"Gimana ""mood"" alam semesta hari ini?",Lagi nangis (Hujan).,Lagi senyum (Cerah).,Lagi diem (Dingin).,Lagi marah (Panas). +35,Lagi butuh asupan yang suhunya...,Panas/Mendidih.,Dingin/Segar.,Hangat/Nyaman.,Beku/Es banyak. +36,Kondisi cuaca ini termasuk kategori...,Syahdu (Hujan).,Ceria (Cerah).,Nyaman (Dingin).,Menyiksa (Panas). +37,Apa yang paling menonjol di luar?,Awan hitam.,Langit biru.,Angin sejuk.,Uap panas. +38,Kamu pengen menu yang bisa...,Ngusir kedinginan hujan.,Nambah semangat cerah.,Nemenin ademnya hari.,Ngusir gerah yang parah. +39,"Seberapa ""ekstrim"" suhu di luar?",Basah maksimal.,Terik maksimal.,Dingin maksimal.,Panas maksimal. +40,Apa elemen alam yang dominan?,Air (Hujan).,Cahaya (Cerah).,Angin (Dingin).,Api/Hawa (Panas). +41,Gimana kondisi pandangan mata?,Kabur karena hujan.,Jelas dan cerah.,Sayu karena dingin.,Berkunang karena panas. +42,Butuh menu yang bikin kamu...,Hangat di dalam.,Segar di mata.,Tenang di hati.,Adem di tenggorokan. +43,Lagi ada fenomena apa di luar?,Hujan petir.,Panas terik.,Angin kencang.,Kemarau panjang. +44,Cuaca ini paling pas ditemani...,Kopi susu hangat.,Lemon tea dingin.,Matcha latte hangat.,Es cokelat pekat. +45,Gimana tingkat kelembapan udara?,Tinggi banget (Hujan).,Pas/Kering (Cerah).,Rendah/Dingin.,Sangat pengap (Panas). +46,Kamu masuk kafe buat cari...,Tempat berteduh hujan.,Tempat nongkrong cerah.,Tempat hangat.,Tempat ngadem/AC. +47,Apa yang kamu rasakan di ubun-ubun?,Tetesan air hujan.,Hangat matahari.,Angin lewat.,Keringat mengalir. +48,Cuaca hari ini bikin malas atau rajin?,Malas karena hujan.,Rajin karena cerah.,Malas karena adem.,Rajin cari yang dingin. +49,"Gimana kondisi ""semesta"" kafe ini?",Penyelamat dari hujan.,Penambah keceriaan.,Pelindung dari dingin.,Oase dari panas. +50,"Jadi, kesimpulannya cuaca lagi...","Hujan, butuh hangat.","Cerah, butuh segar.","Dingin, butuh nyaman.","Panas, butuh yang es!" \ No newline at end of file diff --git a/FIREBASE_SETUP_FINAL.md b/FIREBASE_SETUP_FINAL.md new file mode 100644 index 0000000..0cae1c0 --- /dev/null +++ b/FIREBASE_SETUP_FINAL.md @@ -0,0 +1,122 @@ +# ๐Ÿ”ฅ Firebase Setup - Final Steps + +## โœ… **Yang Sudah Selesai:** +- Firebase project configured: `sembarang-826ce` +- FlutterFire CLI setup +- Firebase initialization code updated +- Gemini API key: `AIzaSyBOgaUAFoVOooo8WplbBanntZdqlC7u1bE` + +## ๐Ÿš€ **Langkah Terakhir untuk Google Sign-in:** + +### **1. Enable Google Authentication** + +1. **Buka Firebase Console:** + - https://cons ole.firebase.google.com/project/sembarang-826ce + +2. **Enable Authentication:** + - Klik **Authentication** โ†’ **Get started** + - Klik **Sign-in method** + - Klik **Google** โ†’ **Enable** + - **Project support email**: Pilih email Anda + - Klik **Save** + +### **2. Get Web Client ID** + +1. **Di Firebase Console:** + - Klik **Project Settings** (gear icon) + - Tab **General** + - Scroll ke **Your apps** + - Klik **Web app** (bukan Android app) + - Copy **Web client ID** (format: `960796982375-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com`) + +### **3. Set Environment Variables** + +**Option A: Run Script (Easy)** +```bash +# Edit set_env.bat dan update Web Client ID, lalu run: +set_env.bat +``` + +**Option B: Manual PowerShell** +```powershell +# Set Gemini API Key +$env:GEMINI_API_KEY="AIzaSyBOgaUAFoVOooo8WplbBanntZdqlC7u1bE" + +# Set Google Client ID (replace dengan Web Client ID yang sebenarnya) +$env:GOOGLE_SIGN_IN_CLIENT_ID="960796982375-your_actual_web_client_id.apps.googleusercontent.com" +``` + +### **4. Test Application** + +```bash +flutter run +``` + +## ๐Ÿ” **Expected Results:** + +### **Debug Console Output:** +``` +โœ… Firebase initialized +โœ… Gemini service initialized +=== App Configuration === +API URL: https://dev-api.muningkofie.com +Debug Mode: true +Gemini API Key: Set +Google Sign-In: Set +Config Valid: true +======================== +``` + +### **App Behavior:** +- โœ… No Firebase initialization errors +- โœ… Google Sign-in button works +- โœ… Can authenticate with Google account +- โœ… User data saved to Firestore + +## ๐Ÿ”ง **Troubleshooting:** + +### **Error: "No Firebase App '[DEFAULT]' has been created"** +- Restart terminal after setting environment variables +- Make sure Firebase initialization runs before any Firebase calls + +### **Google Sign-in Error** +- Verify Web Client ID is correct (not Android Client ID) +- Make sure Google authentication is enabled in Firebase Console +- Check SHA-1 fingerprint is added for Android (if testing on device) + +### **API Key Errors** +- Verify Gemini API key is valid at https://makersuite.google.com/app/apikey +- Make sure environment variable is set correctly + +## ๐Ÿ“ฑ **Next Steps After Setup:** + +1. **Test all features:** + - Google Sign-in โœ… + - Camera OCR + - AI recommendations + - Data persistence + +2. **Production setup:** + - Add release SHA-1 fingerprint + - Configure Firebase security rules + - Setup analytics + +## ๐ŸŽฏ **Quick Commands:** + +```bash +# Set environment variables +set_env.bat + +# Run app +flutter run + +# Check Firebase project +firebase projects:list + +# Open Firebase Console +start https://console.firebase.google.com/project/sembarang-826ce +``` + +--- + +**๐ŸŽ‰ Setelah langkah ini selesai, aplikasi Muning Menu Recommender akan fully functional dengan Firebase dan Gemini AI!** diff --git a/Mood.md b/Mood.md new file mode 100644 index 0000000..b47e15c --- /dev/null +++ b/Mood.md @@ -0,0 +1,52 @@ +No,Pertanyaan (Menu Prompt),Jawaban: Senang,Jawaban: Sedih,Jawaban: Kecewa,Jawaban: Marah +1,Bagaimana perasaanmu saat ini?,Lagi hepi banget!,"Lagi sedih, nih.","Jujur, lagi kecewa.",Lagi emosi/marah! +2,Kondisi hatimu lagi gimana?,Cerah ceria!,Mendung/galau.,Lagi zonk/nyesek.,Panas/mau ngamuk! +3,Apa kabar harimu sejauh ini?,Seru dan menyenangkan!,Berat dan menyedihkan.,"Kacau, gak sesuai mauku.",Buruk! Bikin kesel terus. +4,Butuh asupan mood seperti apa?,Yang manis buat nambah hepi.,Yang bisa nemenin nangis.,Yang bisa ngobatin sesal.,Yang dingin biar gak emosi. +5,Level energi mentalmu sekarang?,"Full banget, lagi semangat!","Drop, lagi pengen rebahan.","Low, gara-gara gagal.","Meledak-ledak, lagi murka!" +6,Warna apa yang mewakili moodmu?,Kuning cerah (Ceria),Biru kelabu (Sendu),Abu-abu (Pudar/Loyo),Merah menyala (Marah) +7,Apa yang sedang kamu rasakan?,Sangat puas dan bersyukur.,Hampa dan kesepian.,Merasa dikhianati/pupus.,Dongkol dan muak! +8,Lagi pengen dimanjain rasa apa?,Rasa bahagia yang manis!,Rasa mellow yang tenang.,Rasa yang bisa nenangin.,Rasa yang bisa nahan emosi. +9,Vibes kamu hari ini cenderung ke arah?,Positive Vibes!,Sad Vibes...,Gloomy/Zonk Vibes.,Toxic/Angry Vibes! +10,Gimana senyummu hari ini?,"Lebar banget, lagi seneng!","Hilang, lagi mau nangis.","Tipis, lagi ngerasa gagal.","Gak ada, lagi pengen marah!" +11,Mood kamu di kafe ini lagi apa?,Mau ngerayain sesuatu!,Mau menenangkan diri.,Mau ngilangin nyesek.,Mau dinginin kepala. +12,Apa satu kata untuk jiwamu?,Berbunga!,Patah.,Pudar.,Membara (Emosi). +13,Baterai kebahagiaanmu berapa %?,"100%, lagi hepi maksimal!","10%, lagi sedih banget.","30%, lagi ngerasa kecewa.","0%, lagi emosi tingkat dewa!" +14,Cuaca batinmu sedang...,Terang benderang.,Hujan rintik-rintik.,Berawan mendung.,Badai petir! +15,Lagi ngerasa dunia berpihak padamu?,"Iya, lagi hoki dan hepi!","Nggak, lagi sedih terus.","Nggak, lagi dikecewain.","Nggak, semuanya bikin marah!" +16,Gimana ritme jantungmu?,Menggebu karena senang!,Lambat karena lemas.,Berhenti sesaat (syok/kecewa).,Berdetak kencang (marah)! +17,Lagi pengen self-reward karena apa?,Karena goals tercapai!,Karena lagi butuh hiburan.,Karena lagi gagal/sedih.,Karena capek marah-marah. +18,Apa yang mendominasi pikiranmu?,Hal-hal indah & seru.,Kenangan yang menyedihkan.,Harapan yang gak tercapai.,Masalah yang bikin emosi. +19,Gimana kondisi semangatmu?,Membara dan positif!,Layu dan tidak bergairah.,Patah dan tawar.,Meledak dan tajam! +20,Lagi butuh teman seperti apa?,Teman buat ketawa bareng.,Teman buat curhat sedih.,Teman buat bagi kecewa.,"Jangan ditemenin, lagi marah!" +No,Pertanyaan (Menu Prompt),Jawaban: Senang,Jawaban: Sedih,Jawaban: Kecewa,Jawaban: Marah +21,Lagi merasa hidup ini indah?,Sangat indah & sempurna!,Terasa kelabu & sepi.,Terasa pahit & hambar.,Terasa kacau & berisik! +22,Gimana energi yang kamu bawa?,Energi positif & ceria.,Energi rendah & lemas.,Energi drop karena gagal.,Energi panas & emosi. +23,Apa yang sedang bergejolak?,Rasa syukur yang besar.,Rasa rindu yang perih.,Rasa sesal yang dalam.,Rasa benci yang meluap. +24,"Bagaimana kondisi ""baterai""mu?","Full 100%, siap gas!","Low batt, pengen nangis.","Error, ekspektasi zonk.","Overheat, lagi meledak!" +25,Butuh menu yang rasanya...,Manis & menyegarkan!,Hangat & menenangkan.,Netral & ngobatin nyesek.,Dingin & nahan emosi. +26,Apa yang kamu rasakan sekarang?,Puas banget sama hari ini.,"Hampa, kayak ada yang hilang.",Patah hati sama keadaan.,"Murka, semuanya salah!" +27,Mood-mu lagi di posisi mana?,"Posisi ""On Top""!","Posisi ""Down"" banget.","Posisi ""Zonk"" & gagal.","Posisi ""Senggol Bacok""!" +28,Lagi ingin meluapkan apa?,Tawa dan kegembiraan.,Air mata kesedihan.,Keluh kesah kekecewaan.,Amarah yang tertahan. +29,Seberapa besar keinginan senyum?,"100%, gak berhenti senyum.","0%, lagi mau mewek.","20%, lagi gak mood.","-100%, lagi mau ngamuk!" +30,Apa warna duniamu saat ini?,Pelangi yang cerah.,Biru yang sendu.,Abu-abu yang pudar.,Merah yang membara. +31,Gimana kabar semangatmu?,Lagi semangat-semangatnya!,Lagi layu & tidak berdaya.,Lagi patah & pupus.,Lagi tajam & berbahaya. +32,Lagi ngerasa dihargai atau...,"Dihargai banget, hepi!","Diabaikan, sedih rasanya.","Disepelekan, kecewa deh.","Dihina, jadi emosi!" +33,Suasana batinmu cenderung...,Berbunga-bunga.,Terluka & perih.,Tawar & nyesek.,Panas & gerah. +34,Lagi butuh asupan jiwa ya?,"Iya, buat ngerayain hepi.","Iya, buat nyembuhin sedih.","Iya, buat ngapus kecewa.","Iya, buat ngeredam marah." +35,Gimana progres harimu?,Lancar jaya & asik!,Macet & bikin galau.,"Berantakan, zonk banget.",Rusak! Bikin naik pitam. +36,Perasaan dominan saat masuk?,Exited & tidak sabar!,Melankolis & pengen sepi.,Pesimis & kurang puas.,Agresif & lagi kesel. +37,Lagi ngerasa beruntung?,"Banget, lagi hoki terus!","Enggak, lagi sial & sedih.","Enggak, lagi dapet zonk.","Enggak, malah bikin emosi!" +38,Kondisi pikiranmu sekarang?,Plong & tanpa beban.,Penuh beban & kesedihan.,Penuh sesal & kecewa.,Penuh amarah & dendam. +39,"Kamu lagi merasa ""Cukup""?","Lebih dari cukup, hepi!","Kurang, merasa hampa.","Gak cukup, kecewa deh.","Gak peduli, lagi emosi!" +40,Butuh teman ngobrol?,"Mau dong, lagi hepi nih!","Nanti aja, lagi galau.","Boleh, lagi nyesek nih.","Gak usah, lagi sensi!" +41,Seberapa cerah duniamu?,Terang benderang!,Gelap & mencekam.,Remang-remang/loyo.,Membara/panas! +42,"Lagi pengen ""Self-Healing""?","Gak perlu, lagi hepi kok.","Banget, lagi terluka.","Perlu, lagi kecewa berat.","Butuh, biar gak ngamuk." +43,Gimana detak jantungmu?,Berdegup senang!,Lemah & tak berdaya.,Berhenti (syok/kecewa).,Berdebar kencang (marah). +44,Rasa apa yang paling cocok?,Manis buah yang segar.,Cokelat hangat yang nyaman.,Kopi pahit yang jujur.,Soda dingin yang tajam. +45,"Lagi merasa ""I'm Okay""?",Very Okay! Hepi banget.,"Not Okay, lagi sedih.","Not Okay, lagi zonk.","Not Okay, lagi emosi!" +46,Apa getaran (vibes) hari ini?,Joyful & Fun!,Sad & Lonely.,Disappointed & Failed.,Angry & Annoyed. +47,Ingin memanjakan diri dengan?,Sesuatu yang seru!,Sesuatu yang tenang.,Sesuatu yang beda (nyesek).,Sesuatu yang keras/kuat. +48,Mood level (1-4)?,4 (Sangat Senang).,1 (Sangat Sedih).,2 (Sangat Kecewa).,3 (Sangat Marah). +49,Lagi merasa dicintai?,"Banget, makanya hepi!","Nggak, ngerasa ditinggal.","Nggak, ngerasa dibohongi.","Nggak, malah dikhianati!" +50,"Jadi, pesananmu untuk mood?",Yang bikin makin ceria!,Yang bisa nenangin galau.,Yang bisa ngobatin kecewa.,Yang bisa nurunin emosi. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da1e65d --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# Muning Menu Recommender + +Aplikasi rekomendasi menu kafe Muning Kofie Kediri menggunakan AI dan OCR technology. + +## ๐Ÿš€ Fitur Utama + +- **OCR Menu Recognition**: Scan foto menu menggunakan Google Vision OCR +- **AI-Powered Recommendations**: Rekomendasi personal menggunakan Gemini AI +- **User Preferences**: Sistem preferensi pengguna yang komprehensif +- **Firebase Integration**: Autentikasi dan database real-time +- **Chat Interface**: Tanya jawab dengan AI tentang menu +- **History & Favorites**: Riwayat rekomendasi dan favorit +- **Offline Support**: Cache data untuk penggunaan offline + +## ๐Ÿ› ๏ธ Tech Stack + +- **Framework**: Flutter 3.8.1+ +- **State Management**: Riverpod +- **Database**: Firebase Firestore + Hive (local) +- **Authentication**: Firebase Auth + Google Sign-In +- **AI/ML**: Google Gemini AI + Google ML Kit OCR +- **UI**: Material Design 3 dengan custom theme + +## ๐Ÿ“ฑ Screenshots + +[Screenshots akan ditambahkan setelah implementasi UI selesai] + +## ๐Ÿ”ง Setup & Installation + +### Prerequisites + +1. Flutter SDK 3.8.1 atau lebih baru +2. Dart SDK +3. Android Studio / VS Code +4. Firebase account +5. Google Cloud account (untuk Gemini AI) + +### Environment Variables + +Buat file `.env` di root project: + +```env +GEMINI_API_KEY=your_gemini_api_key_here +GOOGLE_SIGN_IN_CLIENT_ID=your_google_client_id_here +``` + +### Firebase Setup + +1. Buat project baru di [Firebase Console](https://console.firebase.google.com) +2. Enable Authentication (Email/Password & Google) +3. Enable Firestore Database +4. Enable Firebase Messaging +5. Download `google-services.json` dan letakkan di `android/app/` +6. Download `GoogleService-Info.plist` dan letakkan di `ios/Runner/` + +### Installation Steps + +1. Clone repository: +```bash +git clone https://github.com/your-username/muning-menu-recommender.git +cd muning-menu-recommender +``` + +2. Install dependencies: +```bash +flutter pub get +``` + +3. Generate Hive adapters: +```bash +flutter packages pub run build_runner build +``` + +4. Run the app: +```bash +flutter run +``` + +## ๐Ÿ—๏ธ Architecture + +``` +lib/ +โ”œโ”€โ”€ core/ +โ”‚ โ”œโ”€โ”€ config/ # App configuration +โ”‚ โ”œโ”€โ”€ constants/ # App constants +โ”‚ โ””โ”€โ”€ theme/ # App theme & styling +โ”œโ”€โ”€ models/ # Data models +โ”œโ”€โ”€ services/ # Business logic services +โ”œโ”€โ”€ screens/ # UI screens +โ”‚ โ”œโ”€โ”€ auth/ # Authentication screens +โ”‚ โ”œโ”€โ”€ home/ # Home screen +โ”‚ โ”œโ”€โ”€ camera/ # Camera & OCR screens +โ”‚ โ”œโ”€โ”€ recommendation/ # Recommendation screens +โ”‚ โ”œโ”€โ”€ history/ # History screens +โ”‚ โ””โ”€โ”€ profile/ # Profile screens +โ””โ”€โ”€ main.dart # App entry point +``` + +## ๐Ÿงช Testing + +### Unit Tests +```bash +flutter test +``` + +### Integration Tests +```bash +flutter drive --target=test_driver/app.dart +``` + +### Testing Strategy + +- **Black Box Testing**: Equivalence Class Partitioning (ECP), Boundary Value Analysis (BVA), Decision Table +- **SUS Questionnaire**: System Usability Scale integration +- **Performance Testing**: CPU usage (target: 20-36%), FPS (target: 66+) + +## ๐Ÿ“Š Performance Metrics + +- **Target CPU Usage**: 20-36% +- **Target FPS**: 66+ +- **OCR Accuracy**: >90% +- **AI Response Time**: <3 seconds +- **App Launch Time**: <2 seconds + +## ๐Ÿ”’ Security & Privacy + +- Data encryption in transit dan at rest +- GDPR compliance untuk data handling +- Secure API key management +- User consent untuk data collection + +## ๐Ÿš€ Deployment + +### Android APK +```bash +flutter build apk --release +``` + +### Android App Bundle +```bash +flutter build appbundle --release +``` + +### iOS +```bash +flutter build ios --release +``` + +## ๐Ÿค Contributing + +1. Fork the project +2. Create feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to branch (`git push origin feature/AmazingFeature`) +5. Open Pull Request + +## ๐Ÿ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## ๐Ÿ‘ฅ Team + +- **Developer**: [Your Name] +- **UI/UX Designer**: [Designer Name] +- **Project Manager**: [PM Name] + +## ๐Ÿ“ž Support + +Untuk pertanyaan atau dukungan: +- Email: support@muningkofie.com +- WhatsApp: +62xxx-xxxx-xxxx +- Website: https://muningkofie.com + +## ๐Ÿ™ Acknowledgments + +- Muning Kofie Kediri untuk kolaborasi +- Google untuk ML Kit dan Gemini AI +- Firebase untuk backend services +- Flutter team untuk framework yang amazing + +--- + +**ยฉ 2024 Muning Kofie Kediri. All rights reserved.** + + + diff --git a/Suasana.md b/Suasana.md new file mode 100644 index 0000000..0999532 --- /dev/null +++ b/Suasana.md @@ -0,0 +1,51 @@ +No,Pertanyaan (Input Konteks Sosial),Opsi 1: Sendiri (Me-time),Opsi 2: Berdua (Couple/Bestie),Opsi 3: Rame-rame (Group),Opsi 4: Ramai tapi Biasa (Netral) +1,Kamu ke sini bareng siapa hari ini?,Lagi sendirian aja nih.,Berdua sama teman/pacar.,Rame-rame sama geng.,Rombongan tapi santai. +2,Siapa yang nemenin kamu memesan?,Cuma aku dan pikiranku.,Satu orang favoritku.,Pasukan lengkap!,Teman-teman kantor/tugas. +3,Lagi pengen porsi makan buat berapa orang?,Buat aku sendiri aja.,Porsi sharing berdua.,Porsi jumbo buat barengan.,Porsi standar masing-masing. +4,Suasana meja yang kamu butuhin...,Pojokan kecil buat sendiri.,Meja kecil buat berdua.,Meja panjang buat grup.,Meja mana aja yang kosong. +5,Kamu lagi dalam mode apa sekarang?,Mode introvert/Me-time.,Mode deep talk berdua.,Mode party/nongkrong.,"Mode biasa, cuma singgah." +6,Seberapa banyak kursi yang kamu pesan?,Satu kursi cukup.,Dua kursi berhadapan.,"Kursi banyak, mau kumpul.",Kursi secukupnya aja. +7,Datang ke kafe ini dalam rangka...,Menikmati waktu sendiri.,Kencan/Ngobrol berdua.,Rayain sesuatu barengan.,Ngumpul santai biasa. +8,"Apa kamu butuh menu ""Sharing""?","Gak perlu, buat sendiri.","Boleh, buat bagi berdua.",Wajib! Biar hemat bareng.,"Liat nanti, masing-masing." +9,Lagi pengen diganggu atau tidak?,"Jangan, lagi mau sendiri.",Cuma mau fokus berdua.,"Bebas, lagi seru-seruan.","Biasa aja, gak masalah." +10,"Siapa ""partner"" ngopimu kali ini?","Gak ada, lagi mandiri.",Bestie/Pasangan.,Teman-teman sekelas/kerja.,Siapa aja yang lagi ada. +11,Kamu ngerasa meja ini bakal...,Tenang dan sepi.,Penuh dengan obrolan.,Berisik dan seru.,Kondusif dan normal. +12,"Seberapa besar ""space"" yang kamu cari?",Minimalis buat sendiri.,Pas buat berdua.,Luas buat grup besar.,Yang penting bisa duduk. +13,Lagi mau fokus ngerjain sesuatu?,"Iya, sendirian biar fokus.","Iya, nugas bareng teman.","Enggak, lagi mau main.","Iya, tapi sambil ngobrol." +14,Kamu pesan menu ini untuk...,Dinikmati secara privat.,Dimakan bareng pasangan.,Dikeroyok bareng-bareng.,Dimakan santai aja. +15,"Kondisi ""sosial"" kamu saat ini...",Lagi pengen menyepi.,Lagi pengen berduaan.,Lagi pengen heboh.,Lagi pengen biasa aja. +16,Apa kamu butuh privasi lebih?,"Banget, mau sendiri.","Lumayan, mau ngobrol.","Enggak, ramein aja!","Biasa, gak terlalu mikirin." +17,Kamu datang dengan kendaraan apa?,Sendiri (Motor/Ojol).,Berboncengan/Satu mobil.,Konvoi/Banyak kendaraan.,Masing-masing sampai sini. +18,Gimana intensitas obrolan di mejamu?,Hening/Gak ada suara.,Intens dan dalam.,Kencang dan tertawa.,Sedang dan sewajarnya. +19,Kamu pengen menu yang sifatnya...,Personal (Individual).,Sweet (Couple vibes).,Fun (Party vibes).,Casual (Neutral vibes). +20,"Apa kamu bawa ""pasukan""?",Pasukan satu orang (aku).,Duo maut!,Satu batalyon!,Beberapa orang aja. +21,Lagi mau pamer foto (OOTD) bareng?,Foto sendiri aja.,Foto berdua estetik.,Foto grup/reuni.,"Foto menu aja, gak foto." +22,Kamu butuh rekomendasi menu untuk...,Satu orang (Single).,Dua orang (Duo).,Banyak orang (Family/Squad).,Siapa aja yang mau pesan. +23,Suasana hatimu dipengaruhi oleh...,Diriku sendiri.,Orang di depanku.,Orang-orang di sekitarku.,Keadaan kafe secara umum. +24,Seberapa ramai mejamu nanti?,Gak ramai sama sekali.,Ramai suara kami berdua.,Sangat ramai dan heboh.,Ramai yang terkendali. +25,Kamu ngerasa bakal lama di sini?,Lama karena nugas sendiri.,Lama karena ngobrol.,Lama karena nongkrong.,"Sebentar, cuma mampir." +26,Apa kamu butuh stopkontak banyak?,Satu cukup buat laptopku.,Dua buat hp kami.,Banyak buat bareng-bareng.,"Gak perlu, hp penuh." +27,Konteks pertemuan kamu adalah...,Self-reward.,Date/Pertemuan penting.,Gathering/Arisan.,Ngumpul biasa. +28,Lagi mau ngerasain suasana...,Intimate (Sendiri).,Romantic/Close (Berdua).,Energetic (Rame-rame).,Calm (Biasa). +29,Kamu lebih nyaman kalau...,Gak ada yang kenal aku.,Cuma dia yang kenal aku.,Semua orang tau kita seru.,"Biasa aja, sewajarnya." +30,Seberapa banyak piring di meja nanti?,Satu piring saja.,Dua piring cantik.,Banyak piring berantakan.,Beberapa piring standar. +31,"Lagi mau ""kabur"" dari orang lain?","Iya, mau sendiri dulu.","Iya, mau berdua aja.","Enggak, mau cari orang.","Enggak, biasa aja." +32,Apa kamu bawa anak kecil?,"Enggak, sendirian.","Enggak, cuma berdua.","Iya, ada beberapa anak.","Enggak, cuma orang dewasa." +33,Kamu pengen dapet perhatian staf?,"Enggak, mau tenang.",Secukupnya buat kami.,"Iya, buat bantu grup.",Standar pelayanan aja. +34,Menu yang cocok buat statusmu...,Single fighter.,Dynamic duo.,The big family.,People in general. +35,"Lagi pengen ""curhat""?",Curhat ke diri sendiri.,Curhat ke sahabat.,Curhat masal/cerita seru.,"Gak curhat, cuma ngopi." +36,Kamu butuh tempat yang luas gak?,"Enggak, kecil aja oke.","Enggak, yang penting pas.","Iya, biar gak kepisah.","Biasa, yang penting nyaman." +37,Seberapa besar tagihan (bill) nanti?,"Kecil, buat sendiri.","Sedang, buat berdua.","Besar, bayar barengan.","Normal, masing-masing." +38,"Kamu ngerasa ""pede"" datang...",Sendirian ke kafe.,Gandengan sama pacar.,Rame-rame sama geng.,Santai bareng teman. +39,Apa kamu nunggu orang lain lagi?,"Enggak, cukup aku.","Enggak, cuma berdua.","Iya, masih banyak yang OTW.","Mungkin, liat nanti." +40,Suasana mejamu bakal terlihat...,Kosong dan rapi.,Manis dan tertata.,Berantakan dan seru.,Normal dan fungsional. +41,Kamu ngerasa lebih produktif pas...,Sendirian tanpa gangguan.,Diskusi bareng teman.,Di tengah keramaian grup.,"Biasa aja, gimanapun oke." +42,Apa kamu bawa laptop/buku?,"Iya, teman me-time ku.","Iya, buat kerja bareng.","Enggak, mau main aja.","Enggak, cuma bawa hp." +43,Lagi mau ngerayain ulang tahun?,"Enggak, me-time aja.","Iya, ultah dia/aku.","Iya, pesta kecil-kecilan.","Enggak, hari biasa." +44,Kamu mau duduk di mana?,Pojok gelap (Sendiri).,Tengah terang (Berdua).,Sofa besar (Rame-rame).,Kursi mana aja (Biasa). +45,Seberapa kencang tawamu nanti?,"Gak ada tawa, cuma senyum.",Tawa kecil sesekali.,Tawa kencang/heboh.,Tawa sewajarnya. +46,Kamu butuh menu yang...,Cepat habis (Sendiri).,Cantik difoto (Berdua).,Banyak macamnya (Grup).,Enak dan kenyang (Biasa). +47,"Apa kamu ngerasa ""kesepian""?","Enggak, lagi menikmati.","Enggak, ada dia.","Enggak, rame banget!","Biasa aja, gak kepikiran." +48,"Seberapa ""sosial"" hari kamu?",0% (Anti-sosial).,50% (Satu orang).,100% (Social butterfly).,25% (Casual). +49,Kamu butuh rekomendasi buat...,Diriku seutuhnya.,Hubungan kami berdua.,Kebersamaan grup ini.,Pengunjung umum. +50,"Jadi, pesanan ini untuk siapa?",Untuk aku seorang.,Untuk kita berdua.,Untuk rame-rame!,Untuk makan/minum biasa. \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml 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..c70ce1c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // 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 = "com.example.muningasssistant" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.muningasssistant" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + 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/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..2030e04 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,62 @@ +{ + "project_info": { + "project_number": "585062366519", + "project_id": "amoriassistantpro", + "storage_bucket": "amoriassistantpro.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:585062366519:android:5e4ab2e07c25ee4511aeb2", + "android_client_info": { + "package_name": "com.example.muningasssistant" + } + }, + "oauth_client": [ + { + "client_id": "585062366519-i646eej510acfovittotpm7qukv2m104.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.example.muningasssistant", + "certificate_hash": "30a36a5f01812a99308d2d35bbee8b9dd8492920" + } + }, + { + "client_id": "585062366519-n4kpsdhs9to2v9om25id4gme8nfj3i7n.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.example.muningasssistant", + "certificate_hash": "eef8e4c165921f72c02f3346b72025881d73861f" + } + }, + { + "client_id": "585062366519-r3e2vpi177c7ot5sh2p6s3fldaggldnv.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBERuxOdSuThskaras_cJinQHt1cbdsPik" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "585062366519-r3e2vpi177c7ot5sh2p6s3fldaggldnv.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "585062366519-kv3emsr8q9hhhrcdktrpmfl9vnii7dl0.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.example.muningasssistant" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file 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..57ec64d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/muningasssistant/MainActivity.kt b/android/app/src/main/kotlin/com/example/muningasssistant/MainActivity.kt new file mode 100644 index 0000000..bcaba2a --- /dev/null +++ b/android/app/src/main/kotlin/com/example/muningasssistant/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.muningasssistant + +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..89176ef --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,21 @@ +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/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..bd7522f --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,28 @@ +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.7.3" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/animations/.gitkeep b/assets/animations/.gitkeep new file mode 100644 index 0000000..085e9d2 --- /dev/null +++ b/assets/animations/.gitkeep @@ -0,0 +1 @@ +# This file keeps the assets/animations directory in git diff --git a/assets/animations/loginsplashscreen.gif b/assets/animations/loginsplashscreen.gif new file mode 100644 index 0000000..b601524 Binary files /dev/null and b/assets/animations/loginsplashscreen.gif differ diff --git a/assets/fonts/.gitkeep b/assets/fonts/.gitkeep new file mode 100644 index 0000000..55668e4 --- /dev/null +++ b/assets/fonts/.gitkeep @@ -0,0 +1 @@ +# This file keeps the assets/fonts directory in git diff --git a/assets/fonts/Poppins-Bold.ttf b/assets/fonts/Poppins-Bold.ttf new file mode 100644 index 0000000..1982f38 Binary files /dev/null and b/assets/fonts/Poppins-Bold.ttf differ diff --git a/assets/fonts/Poppins-Medium.ttf b/assets/fonts/Poppins-Medium.ttf new file mode 100644 index 0000000..a590f5c Binary files /dev/null and b/assets/fonts/Poppins-Medium.ttf differ diff --git a/assets/fonts/Poppins-Regular.ttf b/assets/fonts/Poppins-Regular.ttf new file mode 100644 index 0000000..0bda228 Binary files /dev/null and b/assets/fonts/Poppins-Regular.ttf differ diff --git a/assets/fonts/Poppins-SemiBold.ttf b/assets/fonts/Poppins-SemiBold.ttf new file mode 100644 index 0000000..c30ad10 Binary files /dev/null and b/assets/fonts/Poppins-SemiBold.ttf differ diff --git a/assets/fonts/README.md b/assets/fonts/README.md new file mode 100644 index 0000000..e495fb7 --- /dev/null +++ b/assets/fonts/README.md @@ -0,0 +1,23 @@ +# Poppins Font Files + +This directory should contain the following Poppins font files: + +- `Poppins-Regular.ttf` +- `Poppins-Medium.ttf` +- `Poppins-SemiBold.ttf` +- `Poppins-Bold.ttf` + +## How to Download Poppins Fonts + +1. Go to [Google Fonts - Poppins](https://fonts.google.com/specimen/Poppins) +2. Click "Download family" button +3. Extract the ZIP file +4. Copy the following files to this directory: + - `Poppins-Regular.ttf` + - `Poppins-Medium.ttf` + - `Poppins-SemiBold.ttf` + - `Poppins-Bold.ttf` + +## Alternative: Use System Fonts + +If you prefer to use system fonts instead, you can modify `pubspec.yaml` to remove the fonts section and use the default system fonts. diff --git a/assets/icons/.gitkeep b/assets/icons/.gitkeep new file mode 100644 index 0000000..452df0b --- /dev/null +++ b/assets/icons/.gitkeep @@ -0,0 +1 @@ +# This file keeps the assets/icons directory in git diff --git a/assets/images/.gitkeep b/assets/images/.gitkeep new file mode 100644 index 0000000..f0fb0c4 --- /dev/null +++ b/assets/images/.gitkeep @@ -0,0 +1 @@ +# This file keeps the assets/images directory in git diff --git a/assets/images/EarlySetup.png b/assets/images/EarlySetup.png new file mode 100644 index 0000000..d6d89d9 Binary files /dev/null and b/assets/images/EarlySetup.png differ diff --git a/assets/images/Loading Scan.gif b/assets/images/Loading Scan.gif new file mode 100644 index 0000000..518c3df Binary files /dev/null and b/assets/images/Loading Scan.gif differ diff --git a/assets/images/onboard1.png b/assets/images/onboard1.png new file mode 100644 index 0000000..d19720e Binary files /dev/null and b/assets/images/onboard1.png differ diff --git a/assets/images/onboard2.png b/assets/images/onboard2.png new file mode 100644 index 0000000..f08205c Binary files /dev/null and b/assets/images/onboard2.png differ diff --git a/assets/images/onboard3.png b/assets/images/onboard3.png new file mode 100644 index 0000000..9d6ef96 Binary files /dev/null and b/assets/images/onboard3.png differ diff --git a/assets/images/overlayimage.png b/assets/images/overlayimage.png new file mode 100644 index 0000000..f622c80 Binary files /dev/null and b/assets/images/overlayimage.png differ diff --git a/assets/images/registerletter.gif b/assets/images/registerletter.gif new file mode 100644 index 0000000..a5474ba Binary files /dev/null and b/assets/images/registerletter.gif differ diff --git a/assets/images/registerletter1.gif b/assets/images/registerletter1.gif new file mode 100644 index 0000000..d583e5e Binary files /dev/null and b/assets/images/registerletter1.gif differ diff --git a/assets/images/registerletter2.gif b/assets/images/registerletter2.gif new file mode 100644 index 0000000..3d07e12 Binary files /dev/null and b/assets/images/registerletter2.gif differ diff --git a/assets/images/registerletter3.gif b/assets/images/registerletter3.gif new file mode 100644 index 0000000..3c1be60 Binary files /dev/null and b/assets/images/registerletter3.gif differ diff --git a/assets/images/registerletter4.gif b/assets/images/registerletter4.gif new file mode 100644 index 0000000..d9a1fb7 Binary files /dev/null and b/assets/images/registerletter4.gif differ diff --git a/assets/images/registerletter5.gif b/assets/images/registerletter5.gif new file mode 100644 index 0000000..54dd433 Binary files /dev/null and b/assets/images/registerletter5.gif differ diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..0783cb1 --- /dev/null +++ b/firebase.json @@ -0,0 +1,25 @@ +{ + "flutter": { + "platforms": { + "android": { + "default": { + "projectId": "amoriassistantpro", + "appId": "1:585062366519:android:5e4ab2e07c25ee4511aeb2", + "fileOutput": "android/app/google-services.json" + } + }, + "dart": { + "lib/firebase_options.dart": { + "projectId": "amoriassistantpro", + "configurations": { + "android": "1:585062366519:android:5e4ab2e07c25ee4511aeb2", + "ios": "1:585062366519:ios:ceb887efe621ca5411aeb2", + "macos": "1:585062366519:ios:ceb887efe621ca5411aeb2", + "web": "1:585062366519:web:b180a75f3950a9ef11aeb2", + "windows": "1:585062366519:web:7cac58aa7583d3eb11aeb2" + } + } + } + } + } +} \ 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..7c56964 --- /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 + 12.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..2a93e9d --- /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 = 12.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 = com.example.muningasssistant; + 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 = com.example.muningasssistant.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 = com.example.muningasssistant.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 = com.example.muningasssistant.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 = 12.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 = 12.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 = com.example.muningasssistant; + 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 = com.example.muningasssistant; + 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..75c74fc --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Muningasssistant + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + muningasssistant + 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 + + + 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/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..e0d85fe --- /dev/null +++ b/lib/core/config/app_config.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; + +class AppConfig { + static const String _prodApiUrl = 'https://api.muningkofie.com'; + static const String _devApiUrl = 'https://dev-api.muningkofie.com'; + static const String _localApiUrl = 'http://localhost:3000'; + + // Environment-based configuration + static String get apiUrl { + if (kDebugMode) { + return _devApiUrl; + } else if (kProfileMode) { + return _prodApiUrl; + } else { + return _prodApiUrl; + } + } + + static bool get isDebugMode => kDebugMode; + static bool get isReleaseMode => kReleaseMode; + static bool get isProfileMode => kProfileMode; + + // Firebase configuration + static const String firebaseProjectId = 'muning-menu-recommender'; + static const String firebaseStorageBucket = + 'muning-menu-recommender.appspot.com'; + + // Google Services + static const String googleSignInClientId = String.fromEnvironment( + 'GOOGLE_SIGN_IN_CLIENT_ID', + defaultValue: + '624527944720-2hngapf8r7d1smh8114uioii2fr4bhv3.apps.googleusercontent.com', // Fallback for development + ); + + // API Keys + static const String geminiApiKey = String.fromEnvironment( + 'GEMINI_API_KEY', + defaultValue: + 'AIzaSyC7mYgCfQCkBVK-Mia2NduOXyRMcsaCh10', // Restored to working key + ); + + // Feature flags + static const bool enableGoogleSignIn = true; + static const bool enableOfflineMode = true; + static const bool enablePushNotifications = true; + static const bool enableAnalytics = true; + static const bool enableCrashlytics = true; + + // App settings + static const int maxRecommendationHistory = 50; + static const int maxImageCacheSize = 100; // MB + static const Duration cacheExpiration = Duration(days: 7); + + // OCR settings + static const double minOcrConfidence = 0.6; + static const int maxOcrRetries = 3; + + // AI settings + static const int maxTokensPerRequest = 8192; + static const double aiTemperature = 0.2; // Rendah = output JSON lebih konsisten + static const int maxConversationHistory = 10; + + // Helper method to check if Gemini is configured + static bool get isGeminiConfigured { + return geminiApiKey.isNotEmpty; + } + + // Validation + static bool get isConfigValid { + if (!isGeminiConfigured) { + debugPrint('Warning: GEMINI_API_KEY is not set'); + return false; + } + return true; + } + + // Debug helpers + static void printConfig() { + if (kDebugMode) { + debugPrint('=== App Configuration ==='); + debugPrint('API URL: $apiUrl'); + debugPrint('Debug Mode: $isDebugMode'); + debugPrint( + 'Gemini API Key: ${geminiApiKey.isNotEmpty ? "Set" : "Not Set"}', + ); + debugPrint( + 'Google Sign-In: ${googleSignInClientId.isNotEmpty ? "Set" : "Not Set"}', + ); + debugPrint('Config Valid: $isConfigValid'); + debugPrint('========================'); + } + } +} diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..4a47cfa --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -0,0 +1,49 @@ +class AppConstants { + // App Info + static const String appName = 'AMORI Menu Recommender'; + static const String appVersion = '1.0.0'; + static const String kafeName = 'Amori Kofie Kediri'; + + // Firebase Collections + static const String usersCollection = 'users'; + static const String recommendationsCollection = 'recommendations'; + static const String menusCollection = 'menus'; + + // Shared Preferences Keys + static const String isFirstLaunchKey = 'is_first_launch'; + static const String userPreferencesKey = 'user_preferences'; + static const String themeKey = 'theme_mode'; + + // Hive Boxes + static const String userPreferencesBox = 'user_preferences'; + static const String recommendationsBox = 'recommendations'; + static const String menusBox = 'menus'; + + // API Keys (akan diisi dari environment variables) + static const String geminiApiKey = String.fromEnvironment('GEMINI_API_KEY'); + + // Image Picker Settings + static const int maxImageWidth = 1024; + static const int maxImageHeight = 1024; + static const int imageQuality = 85; + + // OCR Settings + static const double ocrConfidenceThreshold = 0.7; + + // UI Constants + static const double defaultPadding = 16.0; + static const double smallPadding = 8.0; + static const double largePadding = 24.0; + static const double borderRadius = 12.0; + static const double smallBorderRadius = 8.0; + static const double largeBorderRadius = 20.0; + + // Animation Durations + static const Duration shortAnimation = Duration(milliseconds: 200); + static const Duration mediumAnimation = Duration(milliseconds: 300); + static const Duration longAnimation = Duration(milliseconds: 500); + + // Network Timeouts + static const Duration connectionTimeout = Duration(seconds: 30); + static const Duration receiveTimeout = Duration(seconds: 30); +} diff --git a/lib/core/data/onboarding_questions.dart b/lib/core/data/onboarding_questions.dart new file mode 100644 index 0000000..3696561 --- /dev/null +++ b/lib/core/data/onboarding_questions.dart @@ -0,0 +1,1099 @@ +class OnboardingQuestion { + final String id; + final String text; + final List options; + final String + category; // e.g., 'sweetness', 'body', 'acidity', 'temperature', 'mood' + + const OnboardingQuestion({ + required this.id, + required this.text, + required this.options, + required this.category, + }); +} + +// Helper to get random questions +List getRandomQuestions(int count) { + final List shuffled = List.from(onboardingQuestionDataset) + ..shuffle(); + return shuffled.take(count).toList(); +} + +const List onboardingQuestionDataset = [ + // --- SWEETNESS PREFERENCES (20 Questions) --- + OnboardingQuestion( + id: 'sweet_1', + text: 'Seberapa manis kopi yang kamu inginkan hari ini?', + options: [ + 'Tanpa gula (Pahit)', + 'Sedikit manis', + 'Manis sedang', + 'Sangat manis (Seperti dessert)', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_2', + text: 'Jika harus memilih camilan, kamu lebih suka yang mana?', + options: [ + 'Dark Chocolate (Pahit)', + 'Buah-buahan segar', + 'Kue Coklat Manis', + 'Permen atau Gulali', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_3', + text: 'Bagaimana pendapatmu tentang sirup vanila atau karamel di kopimu?', + options: [ + 'Tidak suka, merusak rasa kopi', + 'Boleh sedikit saja', + 'Suka, menambah aroma', + 'Wajib ada!', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_4', + text: 'Pilih minuman penutup makan siangmu:', + options: [ + 'Air mineral / Teh tawar', + 'Jus buah murni', + 'Es teh manis', + 'Milkshake Coklat', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_5', + text: 'Apakah kamu suka rasa pahit alami kopi?', + options: [ + 'Ya, sangat suka', + 'Suka tapi ada batasnya', + 'Kurang suka', + 'Tidak suka sama sekali', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_6', + text: 'Jenis cokelat apa yang paling kamu sukai?', + options: [ + '99% Dark Chocolate', + '70% Dark Chocolate', + 'Milk Chocolate', + 'White Chocolate', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_7', + text: 'Bagaimana takaran gula idealmu?', + options: [ + '0% (No sugar)', + '25% (Less sugar)', + '50% (Normal)', + '100% (Extra sugar)', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_8', + text: 'Kamu lebih tim mana?', + options: [ + 'Tim Americano (Pahit)', + 'Tim Latte (Creamy)', + 'Tim Kopi Susu Gula Aren (Manis Legit)', + 'Tim Frappuccino (Manis Dingin)', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_9', + text: 'Saat minum teh, seberapa banyak gula yang kamu tambahkan?', + options: [ + 'Tidak pakai gula', + 'Satu sendok kecil', + 'Dua sendok makan', + 'Susu kental manis yang banyak', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_10', + text: 'Apakah kamu suka topping manis seperti whip cream?', + options: [ + 'Tidak suka', + 'Kadang-kadang', + 'Suka', + 'Sangat suka, tolong yang banyak!', + ], + category: 'sweetness', + ), + // (Adding more variations of sweetness questions to reach volume) + OnboardingQuestion( + id: 'sweet_11', + text: 'Apa definisi "nikmat" bagimu?', + options: [ + 'Rasa asli yang kuat', + 'Keseimbangan rasa', + 'Kelembutan susu', + 'Ledakan rasa manis', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_12', + text: 'Pilih dessert pendamping kopi:', + options: [ + 'Kacang panggang', + 'Croissant polos', + 'Pisang goreng', + 'Donat gula / Bomboloni', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_13', + text: 'Reaksi pertamamu saat minum kopi hitam tanpa gula?', + options: [ + 'Segar dan nikmat', + 'Biasa saja', + 'Perlu sedikit gula', + 'Terlalu pahit, tidak kuat', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_14', + text: 'Gula aren atau Gula pasir?', + options: [ + 'Tidak keduanya', + 'Gula pasir (Netral)', + 'Gula aren (Wangi)', + 'Sirup perasa (Hazelnut/Caramel)', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_15', + text: 'Seberapa sering kamu minum minuman manis dalam seminggu?', + options: ['Hampir tidak pernah', '1-2 kali', '3-5 kali', 'Setiap hari'], + category: 'sweetness', + ), + + // --- TEXTURE / BODY (15 Questions) --- + OnboardingQuestion( + id: 'body_1', + text: 'Bagaimana tekstur minuman yang kamu sukai?', + options: [ + 'Ringan seperti air (Light)', + 'Sedikit kental (Medium)', + 'Kental dan Creamy (Heavy)', + 'Sangat kental (Thick)', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_2', + text: 'Pilih jenis susu favoritmu:', + options: [ + 'Tanpa susu (Hitam)', + 'Skim Milk (Cair)', + 'Fresh Milk (Standard)', + 'Oat Milk / Creamer (Kental)', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_3', + text: + 'Apakah kamu suka sensasi "lengket" atau *mouthfeel* yang tebal di mulut?', + options: [ + 'Tidak, ingin yang bersih', + 'Sedikit boleh', + 'Suka yang lembut', + 'Sangat suka yang *rich*', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_4', + text: 'Kopi tubruk (dengan ampas) atau Kopi saring (bersih)?', + options: [ + 'Kopi saring (Clean)', + 'Tergantung suasana', + 'Kopi tubruk (Bold)', + 'Tidak tahu bedanya', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_5', + text: 'Mana yang lebih menggugah selera?', + options: [ + 'Teh melati bening', + 'Jus jeruk nipis', + 'Smoothie Alpukat', + 'Bubur kacang hijau kental', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_6', + text: 'Suka kopi dengan foam tebal (seperti Cappuccino)?', + options: [ + 'Tidak perlu foam', + 'Sedikit foam (Latte)', + 'Foam tebal (Cappuccino)', + 'Foam melimpah ruah', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_7', + text: 'Pilih tekstur makanan:', + options: ['Keripik renyah', 'Roti panggang', 'Puding lembut', 'Keju leleh'], + category: 'body', + ), + OnboardingQuestion( + id: 'body_8', + text: 'Bagaimana pendapatmu tentang santan?', + options: [ + 'Terlalu berat', + 'Biasa saja', + 'Suka, gurih', + 'Sangat suka teksturnya', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_9', + text: 'Apakah kamu suka minuman bersoda?', + options: [ + 'Suka sensasi menggelitiknya', + 'Biasa saja', + 'Tidak suka sodanya', + 'Lebih suka yang *flat* dan *creamy*', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_10', + text: 'Sensasi akhir (aftertaste) yang diinginkan?', + options: [ + 'Bersih, hilang cepat', + 'Tertinggal sedikit', + 'Awet di lidah', + 'Melapisi seluruh mulut', + ], + category: 'body', + ), + + // --- ACIDITY / FRUITY (15 Questions) --- + OnboardingQuestion( + id: 'acidity_1', + text: + 'Bagaimana pendapatmu tentang rasa asam segar (seperti jeruk) dalam kopi?', + options: [ + 'Tidak suka sama sekali', + 'Sedikit boleh', + 'Suka, menyegarkan', + 'Sangat suka (Fruity)', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_2', + text: 'Pilih buah favoritmu:', + options: [ + 'Pisang (Manis)', + 'Apel (Renya)', + 'Stroberi (Asam Manis)', + 'Lemon / Jeruk Nipis (Asam)', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_3', + text: 'Apakah kamu pernah mencoba kopi Arabika (cenderung asam)?', + options: [ + 'Belum pernah / Tidak suka', + 'Pernah, biasa saja', + 'Pernah, lumayan', + 'Itu favorit saya!', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_4', + text: 'Suka yoghurt plain (asam)?', + options: [ + 'Tidak suka, terlalu asam', + 'Harus pakai gula banyak', + 'Suka sedikit asam', + 'Sangat suka rasa asamnya', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_5', + text: 'Rujak cuka atau Rujak manis?', + options: [ + 'Rujak manis (Gula merah)', + 'Rujak kacang', + 'Campur', + 'Rujak cuka (Asam pedas)', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_6', + text: 'Pilih aroma kopi:', + options: [ + 'Cokelat & Kacang (Dark)', + 'Rempah-rempah', + 'Bunga-bungaan (Floral)', + 'Buah-buahan (Fruity)', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_7', + text: 'Minuman penyegar favorit?', + options: ['Es Kelapa Muda', 'Es Cendol', 'Es Lemon Tea', 'Jus Sirsak'], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_8', + text: 'Bagaimana toleransi lambungmu terhadap asam?', + options: [ + 'Sangat sensitif (Mudah sakit)', + 'Agak sensitif', + 'Cukup kuat', + 'Sangat kuat (Tahan banting)', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_9', + text: 'Suka rasa cuka pempek?', + options: ['Terlalu tajam', 'Sedikit oke', 'Suka', 'Sangat suka kuah cuko'], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_10', + text: 'Kopi "Cold Brew" biasanya lebih tidak asam. Tertarik?', + options: [ + 'Lebih suka yang panas', + 'Biasa saja', + 'Menarik untuk dicoba', + 'Ya, saya cari yang aman di lambung', + ], + category: 'acidity', + ), + + // --- TEMPERATURE (10 Questions) --- + OnboardingQuestion( + id: 'temp_1', + text: 'Cuaca sedang panas terik, kamu pilih:', + options: [ + 'Tetap Kopi Panas', + 'Kopi Hangat', + 'Es Kopi Segar', + 'Es Kopi Blended (Frappe)', + ], + category: 'temperature', + ), + OnboardingQuestion( + id: 'temp_2', + text: 'Saat hujan turun, apa yang paling nyaman?', + options: [ + 'Kopi Panas mengepul', + 'Cokelat Panas', + 'Teh Hangat', + 'Tetap Es Kopi dong', + ], + category: 'temperature', + ), + OnboardingQuestion( + id: 'temp_3', + text: 'Apakah kamu tipe yang meminum kopi dengan cepat?', + options: [ + 'Ya, langsung tandas', + 'Santai, perlahan', + 'Sangat lama sampai dingin', + 'Tergantung kesibukan', + ], + category: 'temperature', + ), + OnboardingQuestion( + id: 'temp_4', + text: 'Pilih sensasi di tenggorokan:', + options: [ + 'Hangat menenangkan', + 'Netral (Suhu ruang)', + 'Sejuk', + 'Dingin menyegarkan (Brain freeze)', + ], + category: 'temperature', + ), + OnboardingQuestion( + id: 'temp_5', + text: 'Gelas favoritmu?', + options: [ + 'Cangkir keramik tebal (Tahan panas)', + 'Gelas kaca cantik', + 'Tumbler stainless', + 'Cup plastik takeaway', + ], + category: 'temperature', + ), + + // --- MOOD / VIBE (20 Questions) --- + OnboardingQuestion( + id: 'mood_1', + text: 'Apa tujuanmu minum kopi saat ini?', + options: [ + 'Melek begadang (Kafein!)', + 'Rileksasi / Santai', + 'Cari inspirasi', + 'Sekedar haus / Teman ngobrol', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_2', + text: 'Bagaimana perasaanmu *beberapa jam* setelah minum kopi kuat?', + options: [ + 'Jantung berdebar / Cemas', + 'Biasa saja', + 'Lebih fokus & Energik', + 'Malah mengantuk', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_3', + text: 'Pilih suasana kafe yang paling kamu suka:', + options: [ + 'Ramai & Energik (Musik kencang)', + 'Minimalis & Terang', + 'Vintage & Klasik (Tenang)', + 'Outdoor / Alam', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_4', + text: 'Kalau kopi itu musik, kamu pilih genre apa?', + options: [ + 'Jazz (Halus & Kompleks)', + 'Pop (Manis & Populer)', + 'Rock (Keras & Kuat)', + 'Lo-fi (Santai & Ringan)', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_5', + text: 'Jam berapa biasanya kamu ngopi?', + options: [ + 'Pagi buta (Morning person)', + 'Siang hari (Lunch break)', + 'Sore santai (Golden hour)', + 'Malam hari (Night owl)', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_6', + text: 'Apakah kamu suka mencoba menu aneh/baru?', + options: [ + 'Tidak, pesanan saya selalu sama', + 'Terkadang kalau direkomendasikan', + 'Sering coba-coba', + 'Selalu cari menu "Signature" unik', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_7', + text: 'Pilih warna yang menggambarkan mood-mu hari ini:', + options: [ + 'Hitam / Coklat Tua', + 'Krem / Putih', + 'Oranye / Kuning Cerah', + 'Biru / Hijau Tenang', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_8', + text: 'Seberapa "strong" kopimu harusnya?', + options: [ + 'Sangat ringan (encer)', + 'Sedang-sedang saja', + 'Kuat napol', + 'Double shot espresso!', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_9', + text: 'Kopi adalah...', + options: ['Kebutuhan hidup', 'Gaya hidup', 'Penikmat rasa', 'Obat ngantuk'], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_10', + text: 'Lebih suka minum sendiri atau ramai-ramai?', + options: [ + 'Me Time (Sendiri)', + 'Berdua pasangan', + 'Meeting kerja', + 'Nongkrong ramai-ramai', + ], + category: 'mood', + ), + + // --- FOOD PAIRING (10 Questions) --- + OnboardingQuestion( + id: 'food_1', + text: 'Lagi lapar nggak?', + options: ['Kenyang banget', 'Agak lapar', 'Lapar berat', 'Cuma mau ngemil'], + category: 'food', + ), + OnboardingQuestion( + id: 'food_2', + text: 'Suka sarapan apa?', + options: [ + 'Roti / Pastry', + 'Nasi uduk / Berat', + 'Buah / Sereal', + 'Cuma kopi', + ], + category: 'food', + ), + OnboardingQuestion( + id: 'food_3', + text: 'Pilih pasangan kopi ideal:', + options: [ + 'Donat kampung', + 'Croissant butter', + 'Singkong goreng', + 'Cake coklat', + ], + category: 'food', + ), + OnboardingQuestion( + id: 'food_4', + text: 'Suka rasa gurih (salty) bersama kopi?', + options: [ + 'Tidak cocok', + 'Mungkin roti keju', + 'Suka (Salted Caramel)', + 'Sangat suka (Kombinasi manis-asin)', + ], + category: 'food', + ), + + // --- MISC / FUN (Continued) --- + OnboardingQuestion( + id: 'misc_4', + text: 'Aroma apa yang paling menarik perhatianmu saat masuk kafe?', + options: [ + 'Kopi yang baru digiling', + 'Roti panggang', + 'Parfum ruangan', + 'Bau buku tua', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_5', + text: 'Jika kopi bisa berbicara, apa katanya padamu?', + options: [ + 'Bangunlah!', + 'Santai saja sejenak', + 'Nikmati hidup', + 'Kerja keras lagi!', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_6', + text: 'Pilih tekstur visual yang menarik:', + options: [ + 'Matte dan kasar', + 'Glossy dan mengkilap', + 'Transparan seperti kaca', + 'Logam berkilau', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_7', + text: 'Suka eksperimen dengan latte art?', + options: [ + 'Suka melihatnya (Estetik)', + 'Tidak peduli, yang penting rasa', + 'Ingin bisa membuatnya', + 'Merusak tekstur foam', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_8', + text: 'Pilih wadah minum favorit saat bepergian:', + options: [ + 'Tumbler tahan panas', + 'Botol plastik praktis', + 'Kaleng minuman', + 'Paper cup sekali pakai', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_9', + text: 'Rutinitas pagimu dimulai dengan...', + options: [ + 'Air putih segelas', + 'Langsung kopi hitam', + 'Sarapan berat', + 'Cek media sosial', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_10', + text: 'Suka mencoba kopi dari berbagai daerah (Single Origin)?', + options: [ + 'Ya, suka mengeksplorasi rasa unik', + 'Tidak terlalu paham bedanya', + 'Lebih suka House Blend konsisten', + 'Asal enak, sikat!', + ], + category: 'misc', + ), + + // --- ADDITIONAL SWEETNESS (More Variations) --- + OnboardingQuestion( + id: 'sweet_16', + text: 'Pilih rasa es krim favorit:', + options: [ + 'Dark Chocolate / Coffee', + 'Vanilla Bean', + 'Strawberry Sorbet', + 'Salted Caramel', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_17', + text: 'Suka madu sebagai pemanis?', + options: [ + 'Suka, lebih alami', + 'Tidak, rasanya aneh di kopi', + 'Hanya di teh', + 'Biasa saja', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_18', + text: 'Bagaimana dengan gula halus (powdered sugar) di donat?', + options: [ + 'Sangat suka manisnya', + 'Disingkirkan sebagian', + 'Terlalu manis', + 'Enak buat kotor-kotoran', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_19', + text: 'Pilih pemanis alternatif:', + options: [ + 'Stevia / Diet Sugar', + 'Brown Sugar', + 'Madu', + 'Tidak pakai pemanis apapun', + ], + category: 'sweetness', + ), + OnboardingQuestion( + id: 'sweet_20', + text: 'Suka permen kopi (Kopiko, dll)?', + options: [ + 'Suka banget', + 'Lumayan buat melek', + 'Terlalu manis', + 'Lebih suka kopi beneran', + ], + category: 'sweetness', + ), + + // --- ADDITIONAL BODY / TEXTURE --- + OnboardingQuestion( + id: 'body_11', + text: 'Pilih sensasi "Creamy":', + options: [ + 'Santan kental', + 'Susu full cream', + 'Yoghurt kental', + 'Alpukat kocok', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_12', + text: 'Suka minuman dengan bulir/jelly (Bubble Tea)?', + options: [ + 'Suka sensasi mengunyah', + 'Tidak, mengganggu minum', + 'Kadang-kadang', + 'Hanya kalau jelly kopi', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_13', + text: 'Kopi Filter V60 biasanya encer dan teh-like. Suka?', + options: [ + 'Suka, ringan dan bersih', + 'Terlalu encer seperti air cuci piring', + 'Belum pernah coba', + 'Tergantung bijinya', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_14', + text: 'Pilih tekstur sup:', + options: [ + 'Sup krim kental (Mushroom soup)', + 'Sup bening (Kaldu)', + 'Soto santan', + 'Rawon pekat', + ], + category: 'body', + ), + OnboardingQuestion( + id: 'body_15', + text: 'Suka ampas kopi yang mengendap (Kopi Turki/Tubruk)?', + options: [ + 'Suka, menambah karakter', + 'Tidak suka, nyangkut di gigi', + 'Biasa saja', + 'Asal halus sekali tidak masalah', + ], + category: 'body', + ), + + // --- ADDITIONAL ACIDITY --- + OnboardingQuestion( + id: 'acidity_11', + text: 'Suka nanas?', + options: [ + 'Suka rasa asam manisnya', + 'Hanya kalau manis', + 'Tidak suka, bikin gatal lidah', + 'Suka buat rujak', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_12', + text: 'Asam jawa (Tamarind) di minuman?', + options: [ + 'Segar banget!', + 'Terlalu asam', + 'Hanya untuk jamu', + 'Enak kalau dingin', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_13', + text: 'Pilih dressing salad:', + options: [ + 'Mayoritas (Creamy)', + 'Vinaigrette (Asam Cuka)', + 'Minyak Zaitun saja', + 'Sambal kacang', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_14', + text: 'Suka sensasi "Sparkling" (Air soda tawar)?', + options: [ + 'Ya, membersihkan lidah', + 'Tidak suka rasanya', + 'Hanya kalau dicampur sirup', + 'Lebih suka air biasa', + ], + category: 'acidity', + ), + OnboardingQuestion( + id: 'acidity_15', + text: 'Jeruk nipis di soto?', + options: [ + 'Wajib, biar segar!', + 'Sedikit saja', + 'Tidak pakai', + 'Pakai cuka saja', + ], + category: 'acidity', + ), + + // --- ADDITIONAL MOOD / HABIT --- + OnboardingQuestion( + id: 'mood_11', + text: 'Saat deadline menumpuk, kamu butuh...', + options: [ + 'Kopi yang nendang (High Caffeine)', + 'Camilan manis', + 'Musik keras', + 'Tidur sebentar', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_12', + text: 'Akhir pekan enaknya...', + options: [ + 'Eksplorasi coffee shop baru', + 'Seduh kopi sendiri di rumah', + 'Minum teh santai', + 'Tidur seharian', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_13', + text: 'Pilih aroma terapi:', + options: [ + 'Lavender (Rileks)', + 'Kopi (Semangat)', + 'Lemon (Segar)', + 'Kayu Cendana (Tenang)', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_14', + text: 'Jika sedang sedih, kamu mencari...', + options: [ + 'Cokelat panas (Comfort drink)', + 'Kopi pahit (Melarung rasa)', + 'Es krim manis', + 'Air putih saja', + ], + category: 'mood', + ), + OnboardingQuestion( + id: 'mood_15', + text: 'Suka pamer foto kopi di medsos?', + options: [ + 'Sering, demi konten', + 'Kadang kalau tempatnya bagus', + 'Jarang sekali', + 'Tidak pernah', + ], + category: 'mood', + ), + + // --- ADDITIONAL FOOD PAIRING --- + OnboardingQuestion( + id: 'food_5', + text: 'Teman kopi sore:', + options: [ + 'Pisang goreng', + 'Biskuit krekers', + 'Kacang rebus', + 'Tidak makan apa-apa', + ], + category: 'food', + ), + OnboardingQuestion( + id: 'food_6', + text: 'Setelah makan pedas, enak minum...', + options: [ + 'Es Teh Manis', + 'Air Hangat', + 'Susu / Kopi Susu (Pereda pedas)', + 'Es Jeruk', + ], + category: 'food', + ), + OnboardingQuestion( + id: 'food_7', + text: 'Sarapan gaya barat (Toast & Egg) cocoknya dengan:', + options: ['Kopi Hitam / Americano', 'Latte', 'Jus Jeruk', 'Susu Putih'], + category: 'food', + ), + OnboardingQuestion( + id: 'food_8', + text: 'Ngemil martabak manis enaknya minum:', + options: [ + 'Kopi Pahit (Penyeimbang)', + 'Teh Tawar', + 'Air Putih', + 'Kopi Susu Manis (Double manis!)', + ], + category: 'food', + ), + + // --- ROAST LEVEL (New Category) --- + OnboardingQuestion( + id: 'roast_1', + text: 'Suka rasa "gosong" atau *smoky* di kopi?', + options: [ + 'Suka, rasa klasik kopi', + 'Sedikit boleh', + 'Tidak suka, terasa pahit tajam', + 'Lebih suka rasa buah/bunga', + ], + category: 'roast', + ), + OnboardingQuestion( + id: 'roast_2', + text: 'Warna biji kopi yang menarik bagimu:', + options: [ + 'Coklat muda (Light Roast)', + 'Coklat sedang (Medium Roast)', + 'Coklat tua berminyak (Dark Roast)', + 'Hitam pekat', + ], + category: 'roast', + ), + OnboardingQuestion( + id: 'roast_3', + text: 'Kopi sachet biasanya Dark Roast. Suka?', + options: [ + 'Suka, praktis dan kuat', + 'Terlalu pahit tanpa gula', + 'Biasa saja', + 'Tidak suka', + ], + category: 'roast', + ), + + // --- ADDITIONAL ROAST (More) --- + OnboardingQuestion( + id: 'roast_4', + text: 'Pernah minum kopi yang rasanya "gosong"?', + options: [ + 'Pernah dan suka', + 'Pernah dan benci', + 'Tidak sadar', + 'Mungkin itu kopi murah', + ], + category: 'roast', + ), + OnboardingQuestion( + id: 'roast_5', + text: 'Suka biji kopi Arabika atau Robusta?', + options: [ + 'Arabika (Asam, Wangi)', + 'Robusta (Pahit, Kuat)', + 'Campuran (Blend)', + 'Tidak tahu bedanya', + ], + category: 'roast', + ), + + // --- ORIGIN / KNOWLEDGE --- + OnboardingQuestion( + id: 'origin_1', + text: 'Kopi Gayo (Aceh) terkenal dengan...', + options: [ + 'Rasa rempah & tebal', + 'Sangat asam', + 'Hambar', + 'Manis seperti gula', + ], + category: 'origin', + ), + OnboardingQuestion( + id: 'origin_2', + text: 'Tertarik belajar cara menyeduh kopi sendiri (Manual Brew)?', + options: [ + 'Sangat tertarik', + 'Sedikit penasaran', + 'Tidak, ribet', + 'Lebih suka dilayani barista', + ], + category: 'origin', + ), + OnboardingQuestion( + id: 'origin_3', + text: 'Pilih negara penghasil kopi imajiner:', + options: [ + 'Brazil (Kacang & Coklat)', + 'Ethiopia (Bunga & Buah)', + 'Vietnam (Kuat & Pekat)', + 'Italia (Espresso Culture)', + ], + category: 'origin', + ), + OnboardingQuestion( + id: 'misc_11', + text: 'Pentingkah "Latte Art" bagimu saat pesan Latte?', + options: [ + 'Penting, buat foto', + 'Senang kalau ada, tapi rasa nomor 1', + 'Tidak penting', + 'Malah sayang diminum', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_12', + text: 'Suka aroma tanah basah sehabis hujan (Petrichor)?', + options: [ + 'Suka, menenangkan', + 'Biasa saja', + 'Tidak suka', + 'Mengingatkan pada kopi earthy', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_13', + text: 'Kalau beli parfum, suka wangi apa?', + options: [ + 'Vanilla / Manis', + 'Jeruk / Segar', + 'Kayu / Maskulin', + 'Bunga / Feminin', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_14', + text: 'Pilih suasana belajar/kerja:', + options: [ + 'Perpustakaan senyap', + 'Coffee shop bising', + 'Taman terbuka', + 'Kamar sendiri dengan musik', + ], + category: 'misc', + ), + OnboardingQuestion( + id: 'misc_15', + text: 'Pilih gelas untuk es kopi:', + options: [ + 'Gelas kaca tinggi (Highball)', + 'Mason Jar (Hipster)', + 'Mug keramik', + 'Plastic cup', + ], + category: 'misc', + ), +]; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..303326a --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + // Coffee-themed colors + static const Color primaryBrown = Color(0xFF8B4513); + static const Color lightBrown = Color(0xFFD2B48C); + static const Color darkBrown = Color(0xFF654321); + static const Color cream = Color(0xFFF5F5DC); + static const Color warmWhite = Color(0xFFFFFDF7); + static const Color coffeeBrown = Color(0xFF6F4E37); + static const Color espresso = Color(0xFF3C2415); + + // Accent colors + static const Color accent = Color(0xFFFF8C42); + static const Color success = Color(0xFF4CAF50); + static const Color warning = Color(0xFFFFC107); + static const Color error = Color(0xFFF44336); + static const Color info = Color(0xFF2196F3); + + // Neutral colors + static const Color grey50 = Color(0xFFFAFAFA); + static const Color grey100 = Color(0xFFF5F5F5); + static const Color grey200 = Color(0xFFEEEEEE); + static const Color grey300 = Color(0xFFE0E0E0); + static const Color grey400 = Color(0xFFBDBDBD); + static const Color grey500 = Color(0xFF9E9E9E); + static const Color grey600 = Color(0xFF757575); + static const Color grey700 = Color(0xFF616161); + static const Color grey800 = Color(0xFF424242); + static const Color grey900 = Color(0xFF212121); + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + primarySwatch: _createMaterialColor(primaryBrown), + primaryColor: primaryBrown, + scaffoldBackgroundColor: warmWhite, + fontFamily: 'Poppins', // Enable Poppins font + + colorScheme: const ColorScheme.light( + primary: primaryBrown, + secondary: accent, + surface: warmWhite, + background: cream, + error: error, + onPrimary: Colors.white, + onSecondary: Colors.white, + onSurface: grey900, + onBackground: grey900, + onError: Colors.white, + ), + + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + }, + ), + + appBarTheme: const AppBarTheme( + backgroundColor: primaryBrown, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryBrown, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: primaryBrown, + side: const BorderSide(color: primaryBrown, width: 2), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: primaryBrown, + textStyle: const TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: grey50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: grey300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: grey300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: primaryBrown, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: error, width: 2), + ), + labelStyle: const TextStyle( + // // fontFamily: 'Poppins', // Using system font + color: grey600, + ), + hintStyle: const TextStyle( + // // fontFamily: 'Poppins', // Using system font + color: grey500, + ), + ), + + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + color: Colors.white, + ), + + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + backgroundColor: Colors.white, + selectedItemColor: primaryBrown, + unselectedItemColor: grey500, + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + + textTheme: const TextTheme( + displayLarge: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 32, + fontWeight: FontWeight.bold, + color: grey900, + ), + displayMedium: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 28, + fontWeight: FontWeight.bold, + color: grey900, + ), + displaySmall: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 24, + fontWeight: FontWeight.w600, + color: grey900, + ), + headlineLarge: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 22, + fontWeight: FontWeight.w600, + color: grey900, + ), + headlineMedium: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 20, + fontWeight: FontWeight.w600, + color: grey900, + ), + headlineSmall: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 18, + fontWeight: FontWeight.w600, + color: grey900, + ), + titleLarge: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 16, + fontWeight: FontWeight.w600, + color: grey900, + ), + titleMedium: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 14, + fontWeight: FontWeight.w500, + color: grey900, + ), + titleSmall: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 12, + fontWeight: FontWeight.w500, + color: grey900, + ), + bodyLarge: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 16, + fontWeight: FontWeight.normal, + color: grey800, + ), + bodyMedium: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 14, + fontWeight: FontWeight.normal, + color: grey800, + ), + bodySmall: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 12, + fontWeight: FontWeight.normal, + color: grey600, + ), + labelLarge: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 14, + fontWeight: FontWeight.w500, + color: grey700, + ), + labelMedium: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 12, + fontWeight: FontWeight.w500, + color: grey700, + ), + labelSmall: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 10, + fontWeight: FontWeight.w500, + color: grey600, + ), + ), + ); + } + + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + primarySwatch: _createMaterialColor(lightBrown), + primaryColor: lightBrown, + scaffoldBackgroundColor: grey900, + + // // fontFamily: 'Poppins', // Using system font // Disabled until Poppins fonts are added + colorScheme: const ColorScheme.dark( + primary: lightBrown, + secondary: accent, + surface: grey800, + background: grey900, + error: error, + onPrimary: grey900, + onSecondary: Colors.white, + onSurface: Colors.white, + onBackground: Colors.white, + onError: Colors.white, + ), + + appBarTheme: const AppBarTheme( + backgroundColor: grey900, + foregroundColor: Colors.white, + elevation: 0, + centerTitle: true, + titleTextStyle: TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: lightBrown, + foregroundColor: grey900, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + // fontFamily: 'Poppins', // Using system font + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + color: grey800, + ), + ); + } + + static MaterialColor _createMaterialColor(Color color) { + List strengths = [.05]; + Map swatch = {}; + final int r = color.red, g = color.green, b = color.blue; + + for (int i = 1; i < 10; i++) { + strengths.add(0.1 * i); + } + for (var strength in strengths) { + final double ds = 0.5 - strength; + swatch[(strength * 1000).round()] = Color.fromRGBO( + r + ((ds < 0 ? r : (255 - r)) * ds).round(), + g + ((ds < 0 ? g : (255 - g)) * ds).round(), + b + ((ds < 0 ? b : (255 - b)) * ds).round(), + 1, + ); + } + return MaterialColor(color.value, swatch); + } +} diff --git a/lib/core/utils/page_transitions.dart b/lib/core/utils/page_transitions.dart new file mode 100644 index 0000000..745c802 --- /dev/null +++ b/lib/core/utils/page_transitions.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +/// Custom page route with smooth fade transition +class FadePageRoute extends PageRouteBuilder { + final Widget page; + final Duration duration; + + FadePageRoute({ + required this.page, + this.duration = const Duration(milliseconds: 400), + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: duration, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation.drive( + Tween( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeInOut)), + ), + child: child, + ); + }, + ); +} + +/// Custom page route with slide up + fade transition +class SlideUpFadePageRoute extends PageRouteBuilder { + final Widget page; + final Duration duration; + + SlideUpFadePageRoute({ + required this.page, + this.duration = const Duration(milliseconds: 350), + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: duration, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final offsetAnimation = + Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ); + + final fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeIn)); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition(position: offsetAnimation, child: child), + ); + }, + ); +} + +/// Custom page route with scale + fade transition (good for splash to onboarding) +class ScaleFadePageRoute extends PageRouteBuilder { + final Widget page; + final Duration duration; + + ScaleFadePageRoute({ + required this.page, + this.duration = const Duration(milliseconds: 450), + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: duration, + reverseTransitionDuration: duration, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final scaleAnimation = Tween(begin: 0.95, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ); + + final fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeIn)); + + return FadeTransition( + opacity: fadeAnimation, + child: ScaleTransition(scale: scaleAnimation, child: child), + ); + }, + ); +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000..1202c2d --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,86 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + return macos; + case TargetPlatform.windows: + return windows; + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyDVlok-oM9tt2d0hCpc22eseTSAMOrA11o', + appId: '1:585062366519:web:b180a75f3950a9ef11aeb2', + messagingSenderId: '585062366519', + projectId: 'amoriassistantpro', + authDomain: 'amoriassistantpro.firebaseapp.com', + storageBucket: 'amoriassistantpro.firebasestorage.app', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyBERuxOdSuThskaras_cJinQHt1cbdsPik', + appId: '1:585062366519:android:5e4ab2e07c25ee4511aeb2', + messagingSenderId: '585062366519', + projectId: 'amoriassistantpro', + storageBucket: 'amoriassistantpro.firebasestorage.app', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyCcUYnvtDUcKjpepYZbZETYwl9szjO3YYE', + appId: '1:585062366519:ios:ceb887efe621ca5411aeb2', + messagingSenderId: '585062366519', + projectId: 'amoriassistantpro', + storageBucket: 'amoriassistantpro.firebasestorage.app', + iosBundleId: 'com.example.muningasssistant', + ); + + static const FirebaseOptions macos = FirebaseOptions( + apiKey: 'AIzaSyCcUYnvtDUcKjpepYZbZETYwl9szjO3YYE', + appId: '1:585062366519:ios:ceb887efe621ca5411aeb2', + messagingSenderId: '585062366519', + projectId: 'amoriassistantpro', + storageBucket: 'amoriassistantpro.firebasestorage.app', + iosBundleId: 'com.example.muningasssistant', + ); + + static const FirebaseOptions windows = FirebaseOptions( + apiKey: 'AIzaSyDVlok-oM9tt2d0hCpc22eseTSAMOrA11o', + appId: '1:585062366519:web:7cac58aa7583d3eb11aeb2', + messagingSenderId: '585062366519', + projectId: 'amoriassistantpro', + authDomain: 'amoriassistantpro.firebaseapp.com', + storageBucket: 'amoriassistantpro.firebasestorage.app', + ); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..b213cbf --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; + +import 'core/config/app_config.dart'; +import 'core/theme/app_theme.dart'; +// import 'services/firebase_service.dart'; // Not needed for direct Firebase initialization +import 'services/gemini_service.dart'; +import 'services/dialog_service.dart'; +// TODO: Import models when Hive adapters are generated +import 'models/user_model.dart'; +import 'models/menu_model.dart'; +import 'models/recommendation_model.dart'; +import 'screens/splash_screen.dart'; +import 'core/constants/app_constants.dart'; +import 'firebase_options.dart'; + +// Background message handler +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Only initialize if not already initialized + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } + debugPrint('Handling a background message: ${message.messageId}'); +} + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Set system UI overlay style + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + systemNavigationBarColor: Colors.white, + systemNavigationBarIconBrightness: Brightness.dark, + ), + ); + + // Set preferred orientations + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + try { + // Initialize Hive + await Hive.initFlutter(); + + // Register Hive adapters + Hive.registerAdapter(UserModelAdapter()); + Hive.registerAdapter(UserPreferencesAdapter()); + Hive.registerAdapter(MenuModelAdapter()); + Hive.registerAdapter(RecommendationModelAdapter()); + Hive.registerAdapter(MenuRecommendationAdapter()); + + // Open Hive boxes + await Future.wait([ + Hive.openBox(AppConstants.userPreferencesBox), + Hive.openBox(AppConstants.recommendationsBox), + Hive.openBox(AppConstants.menusBox), + ]); + + // Initialize Firebase (handle duplicate app error gracefully) + try { + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + } + debugPrint('โœ… Firebase initialized'); + } catch (e) { + if (e.toString().contains('duplicate-app')) { + debugPrint('โš ๏ธ Firebase already initialized, using existing instance'); + } else { + rethrow; + } + } + + // Set up background message handler + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // Initialize Gemini service + try { + await GeminiService().initialize(); + debugPrint('โœ… Gemini service initialized'); + } catch (e) { + debugPrint('โš ๏ธ Gemini service initialization failed: $e'); + debugPrint('๐Ÿ’ก Users can set their own API key in settings'); + } + + // Print configuration in debug mode + AppConfig.printConfig(); + + runApp(const ProviderScope(child: AmoriMenuRecommenderApp())); + } catch (e) { + debugPrint('Initialization error: $e'); + runApp( + MaterialApp( + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + const Text( + 'Gagal menginisialisasi aplikasi', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + 'Error: $e', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // Restart app + main(); + }, + child: const Text('Coba Lagi'), + ), + ], + ), + ), + ), + ), + ); + } +} + +class AmoriMenuRecommenderApp extends ConsumerWidget { + const AmoriMenuRecommenderApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp( + title: AppConstants.appName, + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.system, + // Global Keys for DialogService + navigatorKey: DialogService().navigatorKey, + scaffoldMessengerKey: DialogService().scaffoldMessengerKey, + home: const SplashScreen(), + builder: (context, child) { + // Handle global errors + ErrorWidget.builder = (FlutterErrorDetails errorDetails) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + const Text( + 'Terjadi kesalahan', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + if (AppConfig.isDebugMode) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + errorDetails.exception.toString(), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + // Navigate back or restart + Navigator.of(context).pop(); + }, + child: const Text('Kembali'), + ), + ], + ), + ), + ); + }; + + return child!; + }, + ); + } +} diff --git a/lib/models/daily_question_model.dart b/lib/models/daily_question_model.dart new file mode 100644 index 0000000..fffda82 --- /dev/null +++ b/lib/models/daily_question_model.dart @@ -0,0 +1,98 @@ +class DailyQuestion { + final String id; + final String question; + final QuestionType type; + final List? options; + final int? minValue; + final int? maxValue; + final int? step; + + /// If set, this question only shows when the question with [dependsOn] ID + /// has been answered with one of the values in [showWhenAnswers]. + final String? dependsOn; + final List? showWhenAnswers; + + DailyQuestion({ + required this.id, + required this.question, + required this.type, + this.options, + this.minValue, + this.maxValue, + this.step, + this.dependsOn, + this.showWhenAnswers, + }); + + factory DailyQuestion.fromJson(Map json) { + return DailyQuestion( + id: json['id'] as String, + question: json['question'] as String, + type: QuestionType.values.firstWhere( + (e) => e.name == json['type'], + orElse: () => QuestionType.choice, + ), + options: json['options'] != null + ? List.from(json['options']) + : null, + minValue: json['minValue'] as int?, + maxValue: json['maxValue'] as int?, + step: json['step'] as int?, + dependsOn: json['dependsOn'] as String?, + showWhenAnswers: json['showWhenAnswers'] != null + ? List.from(json['showWhenAnswers']) + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'question': question, + 'type': type.name, + 'options': options, + 'minValue': minValue, + 'maxValue': maxValue, + 'step': step, + 'dependsOn': dependsOn, + 'showWhenAnswers': showWhenAnswers, + }; + } + + /// Whether this question should be visible given current answers + bool isVisible(Map currentAnswers) { + if (dependsOn == null || showWhenAnswers == null) return true; + final parentAnswer = currentAnswers[dependsOn]; + if (parentAnswer == null) return false; + return showWhenAnswers!.contains(parentAnswer.toString()); + } +} + +enum QuestionType { + choice, // Multiple choice chips + slider, // Slider for numeric values + text, // Free text input +} + +class DailyQuestionsResponse { + final List questions; + final DateTime generatedAt; + + DailyQuestionsResponse({required this.questions, required this.generatedAt}); + + factory DailyQuestionsResponse.fromJson(Map json) { + return DailyQuestionsResponse( + questions: (json['questions'] as List) + .map((q) => DailyQuestion.fromJson(q)) + .toList(), + generatedAt: DateTime.parse(json['generatedAt'] as String), + ); + } + + Map toJson() { + return { + 'questions': questions.map((q) => q.toJson()).toList(), + 'generatedAt': generatedAt.toIso8601String(), + }; + } +} diff --git a/lib/models/menu_model.dart b/lib/models/menu_model.dart new file mode 100644 index 0000000..7f088f2 --- /dev/null +++ b/lib/models/menu_model.dart @@ -0,0 +1,216 @@ +import 'package:hive/hive.dart'; + +part 'menu_model.g.dart'; + +@HiveType(typeId: 2) +class MenuModel extends HiveObject { + @HiveField(0) + String id; + + @HiveField(1) + String name; + + @HiveField(2) + String description; + + @HiveField(3) + double price; + + @HiveField(4) + String category; + + @HiveField(5) + List ingredients; + + @HiveField(6) + List allergens; + + @HiveField(7) + String spiceLevel; + + @HiveField(8) + bool isVegetarian; + + @HiveField(9) + bool isVegan; + + @HiveField(10) + bool isHalal; + + @HiveField(11) + String? imageUrl; + + @HiveField(12) + double rating; + + @HiveField(13) + int reviewCount; + + @HiveField(14) + bool isAvailable; + + @HiveField(15) + DateTime createdAt; + + @HiveField(16) + DateTime updatedAt; + + MenuModel({ + required this.id, + required this.name, + required this.description, + required this.price, + required this.category, + this.ingredients = const [], + this.allergens = const [], + this.spiceLevel = 'mild', + this.isVegetarian = false, + this.isVegan = false, + this.isHalal = true, + this.imageUrl, + this.rating = 0.0, + this.reviewCount = 0, + this.isAvailable = true, + required this.createdAt, + required this.updatedAt, + }); + + factory MenuModel.fromJson(Map json) { + return MenuModel( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String, + price: (json['price'] as num).toDouble(), + category: json['category'] as String, + ingredients: List.from(json['ingredients'] ?? []), + allergens: List.from(json['allergens'] ?? []), + spiceLevel: json['spiceLevel'] as String? ?? 'mild', + isVegetarian: json['isVegetarian'] as bool? ?? false, + isVegan: json['isVegan'] as bool? ?? false, + isHalal: json['isHalal'] as bool? ?? true, + imageUrl: json['imageUrl'] as String?, + rating: (json['rating'] as num?)?.toDouble() ?? 0.0, + reviewCount: json['reviewCount'] as int? ?? 0, + isAvailable: json['isAvailable'] as bool? ?? true, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'price': price, + 'category': category, + 'ingredients': ingredients, + 'allergens': allergens, + 'spiceLevel': spiceLevel, + 'isVegetarian': isVegetarian, + 'isVegan': isVegan, + 'isHalal': isHalal, + 'imageUrl': imageUrl, + 'rating': rating, + 'reviewCount': reviewCount, + 'isAvailable': isAvailable, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + } + + MenuModel copyWith({ + String? id, + String? name, + String? description, + double? price, + String? category, + List? ingredients, + List? allergens, + String? spiceLevel, + bool? isVegetarian, + bool? isVegan, + bool? isHalal, + String? imageUrl, + double? rating, + int? reviewCount, + bool? isAvailable, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MenuModel( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + price: price ?? this.price, + category: category ?? this.category, + ingredients: ingredients ?? this.ingredients, + allergens: allergens ?? this.allergens, + spiceLevel: spiceLevel ?? this.spiceLevel, + isVegetarian: isVegetarian ?? this.isVegetarian, + isVegan: isVegan ?? this.isVegan, + isHalal: isHalal ?? this.isHalal, + imageUrl: imageUrl ?? this.imageUrl, + rating: rating ?? this.rating, + reviewCount: reviewCount ?? this.reviewCount, + isAvailable: isAvailable ?? this.isAvailable, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + String get formattedPrice { + return 'Rp ${price.toStringAsFixed(0).replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + )}'; + } + + List get dietaryTags { + List tags = []; + if (isVegetarian) tags.add('Vegetarian'); + if (isVegan) tags.add('Vegan'); + if (isHalal) tags.add('Halal'); + return tags; + } + + String get spiceLevelEmoji { + switch (spiceLevel.toLowerCase()) { + case 'mild': + return '๐ŸŒถ๏ธ'; + case 'medium': + return '๐ŸŒถ๏ธ๐ŸŒถ๏ธ'; + case 'hot': + return '๐ŸŒถ๏ธ๐ŸŒถ๏ธ๐ŸŒถ๏ธ'; + case 'very hot': + return '๐ŸŒถ๏ธ๐ŸŒถ๏ธ๐ŸŒถ๏ธ๐ŸŒถ๏ธ'; + default: + return ''; + } + } + + bool hasAllergen(String allergen) { + return allergens.any((a) => a.toLowerCase() == allergen.toLowerCase()); + } + + bool isCompatibleWithPreferences(List userAllergies, String dietaryRestriction) { + // Check allergies + for (String allergy in userAllergies) { + if (hasAllergen(allergy)) { + return false; + } + } + + // Check dietary restrictions + switch (dietaryRestriction.toLowerCase()) { + case 'vegetarian': + return isVegetarian; + case 'vegan': + return isVegan; + case 'halal': + return isHalal; + default: + return true; + } + } +} diff --git a/lib/models/menu_model.g.dart b/lib/models/menu_model.g.dart new file mode 100644 index 0000000..7c804c2 --- /dev/null +++ b/lib/models/menu_model.g.dart @@ -0,0 +1,89 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'menu_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MenuModelAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + MenuModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MenuModel( + id: fields[0] as String, + name: fields[1] as String, + description: fields[2] as String, + price: fields[3] as double, + category: fields[4] as String, + ingredients: (fields[5] as List).cast(), + allergens: (fields[6] as List).cast(), + spiceLevel: fields[7] as String, + isVegetarian: fields[8] as bool, + isVegan: fields[9] as bool, + isHalal: fields[10] as bool, + imageUrl: fields[11] as String?, + rating: fields[12] as double, + reviewCount: fields[13] as int, + isAvailable: fields[14] as bool, + createdAt: fields[15] as DateTime, + updatedAt: fields[16] as DateTime, + ); + } + + @override + void write(BinaryWriter writer, MenuModel obj) { + writer + ..writeByte(17) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.description) + ..writeByte(3) + ..write(obj.price) + ..writeByte(4) + ..write(obj.category) + ..writeByte(5) + ..write(obj.ingredients) + ..writeByte(6) + ..write(obj.allergens) + ..writeByte(7) + ..write(obj.spiceLevel) + ..writeByte(8) + ..write(obj.isVegetarian) + ..writeByte(9) + ..write(obj.isVegan) + ..writeByte(10) + ..write(obj.isHalal) + ..writeByte(11) + ..write(obj.imageUrl) + ..writeByte(12) + ..write(obj.rating) + ..writeByte(13) + ..write(obj.reviewCount) + ..writeByte(14) + ..write(obj.isAvailable) + ..writeByte(15) + ..write(obj.createdAt) + ..writeByte(16) + ..write(obj.updatedAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MenuModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/recommendation_model.dart b/lib/models/recommendation_model.dart new file mode 100644 index 0000000..ecda939 --- /dev/null +++ b/lib/models/recommendation_model.dart @@ -0,0 +1,269 @@ +import 'package:hive/hive.dart'; +import 'menu_model.dart'; + +part 'recommendation_model.g.dart'; + +@HiveType(typeId: 3) +class RecommendationModel extends HiveObject { + @HiveField(0) + String id; + + @HiveField(1) + String userId; + + @HiveField(2) + List recommendations; + + @HiveField(3) + String originalMenuText; + + @HiveField(4) + String? menuImageUrl; + + @HiveField(5) + String prompt; + + @HiveField(6) + String aiResponse; + + @HiveField(7) + double confidence; + + @HiveField(8) + DateTime createdAt; + + @HiveField(9) + bool isFavorite; + + @HiveField(10) + String sessionId; + + @HiveField(11) + String? selectedMenuName; + + @HiveField(12) + List? menuImagePaths; + + RecommendationModel({ + required this.id, + required this.userId, + required this.recommendations, + required this.originalMenuText, + this.menuImageUrl, + this.menuImagePaths, + required this.prompt, + required this.aiResponse, + this.confidence = 0.0, + required this.createdAt, + this.isFavorite = false, + required this.sessionId, + this.selectedMenuName, + }); + + factory RecommendationModel.fromJson(Map json) { + return RecommendationModel( + id: json['id'] as String, + userId: json['userId'] as String, + recommendations: (json['recommendations'] as List) + .map((e) => MenuRecommendation.fromJson(e as Map)) + .toList(), + originalMenuText: json['originalMenuText'] as String, + menuImageUrl: json['menuImageUrl'] as String?, + menuImagePaths: (json['menuImagePaths'] as List?) + ?.map((e) => e as String) + .toList(), + prompt: json['prompt'] as String, + aiResponse: json['aiResponse'] as String, + confidence: (json['confidence'] as num?)?.toDouble() ?? 0.0, + createdAt: DateTime.parse(json['createdAt'] as String), + isFavorite: json['isFavorite'] as bool? ?? false, + sessionId: json['sessionId'] as String, + selectedMenuName: json['selectedMenuName'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'userId': userId, + 'recommendations': recommendations.map((e) => e.toJson()).toList(), + 'originalMenuText': originalMenuText, + 'menuImageUrl': menuImageUrl, + 'menuImagePaths': menuImagePaths, + 'prompt': prompt, + 'aiResponse': aiResponse, + 'confidence': confidence, + 'createdAt': createdAt.toIso8601String(), + 'isFavorite': isFavorite, + 'sessionId': sessionId, + 'selectedMenuName': selectedMenuName, + }; + } + + RecommendationModel copyWith({ + String? id, + String? userId, + List? recommendations, + String? originalMenuText, + String? menuImageUrl, + List? menuImagePaths, + String? prompt, + String? aiResponse, + double? confidence, + DateTime? createdAt, + bool? isFavorite, + String? sessionId, + String? selectedMenuName, + }) { + return RecommendationModel( + id: id ?? this.id, + userId: userId ?? this.userId, + recommendations: recommendations ?? this.recommendations, + originalMenuText: originalMenuText ?? this.originalMenuText, + menuImageUrl: menuImageUrl ?? this.menuImageUrl, + menuImagePaths: menuImagePaths ?? this.menuImagePaths, + prompt: prompt ?? this.prompt, + aiResponse: aiResponse ?? this.aiResponse, + confidence: confidence ?? this.confidence, + createdAt: createdAt ?? this.createdAt, + isFavorite: isFavorite ?? this.isFavorite, + sessionId: sessionId ?? this.sessionId, + selectedMenuName: selectedMenuName ?? this.selectedMenuName, + ); + } + + List get topRecommendations { + final sorted = List.from(recommendations); + sorted.sort((a, b) => b.score.compareTo(a.score)); + return sorted.take(3).toList(); + } + + String get formattedDate { + final now = DateTime.now(); + final difference = now.difference(createdAt); + + if (difference.inDays > 0) { + return '${difference.inDays} hari yang lalu'; + } else if (difference.inHours > 0) { + return '${difference.inHours} jam yang lalu'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes} menit yang lalu'; + } else { + return 'Baru saja'; + } + } +} + +@HiveType(typeId: 4) +class MenuRecommendation extends HiveObject { + @HiveField(0) + String menuName; + + @HiveField(1) + String description; + + @HiveField(2) + double price; + + @HiveField(3) + String reason; + + @HiveField(4) + double score; + + @HiveField(5) + List tags; + + @HiveField(6) + String category; + + @HiveField(7) + String? imageUrl; + + @HiveField(8) + bool isRecommended; + + @HiveField(9) + String? slotType; + + MenuRecommendation({ + required this.menuName, + required this.description, + required this.price, + required this.reason, + this.score = 0.0, + this.tags = const [], + required this.category, + this.imageUrl, + this.isRecommended = true, + this.slotType, + }); + + factory MenuRecommendation.fromJson(Map json) { + return MenuRecommendation( + menuName: json['menuName'] as String, + description: json['description'] as String, + price: (json['price'] as num).toDouble(), + reason: json['reason'] as String, + score: (json['score'] as num?)?.toDouble() ?? 0.0, + tags: List.from(json['tags'] ?? []), + category: json['category'] as String, + imageUrl: json['imageUrl'] as String?, + isRecommended: json['isRecommended'] as bool? ?? true, + slotType: json['slotType'] as String?, + ); + } + + Map toJson() { + return { + 'menuName': menuName, + 'description': description, + 'price': price, + 'reason': reason, + 'score': score, + 'tags': tags, + 'category': category, + 'imageUrl': imageUrl, + 'isRecommended': isRecommended, + 'slotType': slotType, + }; + } + + MenuRecommendation copyWith({ + String? menuName, + String? description, + double? price, + String? reason, + double? score, + List? tags, + String? category, + String? imageUrl, + bool? isRecommended, + String? slotType, + }) { + return MenuRecommendation( + menuName: menuName ?? this.menuName, + description: description ?? this.description, + price: price ?? this.price, + reason: reason ?? this.reason, + score: score ?? this.score, + tags: tags ?? this.tags, + category: category ?? this.category, + imageUrl: imageUrl ?? this.imageUrl, + isRecommended: isRecommended ?? this.isRecommended, + slotType: slotType ?? this.slotType, + ); + } + + String get formattedPrice { + return 'Rp ${price.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; + } + + int get starRating { + return (score * 5).round().clamp(1, 5); + } + + String get scorePercentage { + return '${(score * 100).toStringAsFixed(0)}%'; + } +} diff --git a/lib/models/recommendation_model.g.dart b/lib/models/recommendation_model.g.dart new file mode 100644 index 0000000..ea94d1c --- /dev/null +++ b/lib/models/recommendation_model.g.dart @@ -0,0 +1,138 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommendation_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class RecommendationModelAdapter extends TypeAdapter { + @override + final int typeId = 3; + + @override + RecommendationModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return RecommendationModel( + id: fields[0] as String, + userId: fields[1] as String, + recommendations: (fields[2] as List).cast(), + originalMenuText: fields[3] as String, + menuImageUrl: fields[4] as String?, + menuImagePaths: (fields[12] as List?)?.cast(), + prompt: fields[5] as String, + aiResponse: fields[6] as String, + confidence: fields[7] as double, + createdAt: fields[8] as DateTime, + isFavorite: fields[9] as bool, + sessionId: fields[10] as String, + selectedMenuName: fields[11] as String?, + ); + } + + @override + void write(BinaryWriter writer, RecommendationModel obj) { + writer + ..writeByte(13) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.userId) + ..writeByte(2) + ..write(obj.recommendations) + ..writeByte(3) + ..write(obj.originalMenuText) + ..writeByte(4) + ..write(obj.menuImageUrl) + ..writeByte(5) + ..write(obj.prompt) + ..writeByte(6) + ..write(obj.aiResponse) + ..writeByte(7) + ..write(obj.confidence) + ..writeByte(8) + ..write(obj.createdAt) + ..writeByte(9) + ..write(obj.isFavorite) + ..writeByte(10) + ..write(obj.sessionId) + ..writeByte(11) + ..write(obj.selectedMenuName) + ..writeByte(12) + ..write(obj.menuImagePaths); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RecommendationModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class MenuRecommendationAdapter extends TypeAdapter { + @override + final int typeId = 4; + + @override + MenuRecommendation read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MenuRecommendation( + menuName: fields[0] as String, + description: fields[1] as String, + price: fields[2] as double, + reason: fields[3] as String, + score: fields[4] as double, + tags: (fields[5] as List).cast(), + category: fields[6] as String, + imageUrl: fields[7] as String?, + isRecommended: fields[8] as bool, + slotType: fields[9] as String?, + ); + } + + @override + void write(BinaryWriter writer, MenuRecommendation obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.menuName) + ..writeByte(1) + ..write(obj.description) + ..writeByte(2) + ..write(obj.price) + ..writeByte(3) + ..write(obj.reason) + ..writeByte(4) + ..write(obj.score) + ..writeByte(5) + ..write(obj.tags) + ..writeByte(6) + ..write(obj.category) + ..writeByte(7) + ..write(obj.imageUrl) + ..writeByte(8) + ..write(obj.isRecommended) + ..writeByte(9) + ..write(obj.slotType); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MenuRecommendationAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/user_model.dart b/lib/models/user_model.dart new file mode 100644 index 0000000..18695e0 --- /dev/null +++ b/lib/models/user_model.dart @@ -0,0 +1,257 @@ +import 'package:hive/hive.dart'; + +part 'user_model.g.dart'; + +@HiveType(typeId: 0) +class UserModel extends HiveObject { + @HiveField(0) + String id; + + @HiveField(1) + String email; + + @HiveField(2) + String? displayName; + + @HiveField(3) + String? photoUrl; + + @HiveField(4) + UserPreferences preferences; + + @HiveField(5) + DateTime createdAt; + + @HiveField(6) + DateTime updatedAt; + + @HiveField(7) + String? phoneNumber; + + @HiveField(8) + bool isSetupComplete; + + @HiveField(9) + String? nickname; + + @HiveField(10) + String? gender; + + @HiveField(11) + String? address; + + @HiveField(12) + DateTime? lastCheckinDate; + + @HiveField(13) + Map? dailyPreferences; + + UserModel({ + required this.id, + required this.email, + this.displayName, + this.photoUrl, + this.phoneNumber, + required this.preferences, + required this.createdAt, + required this.updatedAt, + this.isSetupComplete = false, + this.nickname, + this.gender, + this.address, + this.lastCheckinDate, + this.dailyPreferences, + }); + + factory UserModel.fromJson(Map json) { + return UserModel( + id: json['id'] as String, + email: json['email'] as String, + displayName: json['displayName'] as String?, + phoneNumber: json['phoneNumber'] as String?, + preferences: UserPreferences.fromJson( + json['preferences'] as Map, + ), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + isSetupComplete: json['isSetupComplete'] as bool? ?? false, + nickname: json['nickname'] as String?, + gender: json['gender'] as String?, + address: json['address'] as String?, + lastCheckinDate: json['lastCheckinDate'] != null + ? DateTime.parse(json['lastCheckinDate'] as String) + : null, + dailyPreferences: json['dailyPreferences'] as Map?, + ); + } + + Map toJson() { + return { + 'id': id, + 'email': email, + 'displayName': displayName, + 'photoUrl': photoUrl, + 'phoneNumber': phoneNumber, + 'preferences': preferences.toJson(), + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'isSetupComplete': isSetupComplete, + 'nickname': nickname, + 'gender': gender, + 'address': address, + 'lastCheckinDate': lastCheckinDate?.toIso8601String(), + 'dailyPreferences': dailyPreferences, + }; + } + + UserModel copyWith({ + String? id, + String? email, + String? displayName, + String? photoUrl, + String? phoneNumber, + UserPreferences? preferences, + DateTime? createdAt, + DateTime? updatedAt, + bool? isSetupComplete, + String? nickname, + String? gender, + String? address, + DateTime? lastCheckinDate, + Map? dailyPreferences, + }) { + return UserModel( + id: id ?? this.id, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + photoUrl: photoUrl ?? this.photoUrl, + phoneNumber: phoneNumber ?? this.phoneNumber, + preferences: preferences ?? this.preferences, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + isSetupComplete: isSetupComplete ?? this.isSetupComplete, + nickname: nickname ?? this.nickname, + gender: gender ?? this.gender, + address: address ?? this.address, + lastCheckinDate: lastCheckinDate ?? this.lastCheckinDate, + dailyPreferences: dailyPreferences ?? this.dailyPreferences, + ); + } +} + +@HiveType(typeId: 1) +class UserPreferences extends HiveObject { + @HiveField(0) + List favoriteCategories; + + @HiveField(1) + List allergies; + + @HiveField(2) + String mood; + + @HiveField(3) + String spiceLevel; + + @HiveField(4) + String dietaryRestriction; + + @HiveField(5) + int budgetRange; + + @HiveField(6) + bool enableNotifications; + + @HiveField(7) + String language; + + @HiveField(8) + String? ageRange; + + UserPreferences({ + this.favoriteCategories = const [], + this.allergies = const [], + this.mood = 'neutral', + this.spiceLevel = 'medium', + this.dietaryRestriction = 'none', + this.budgetRange = 50000, + this.enableNotifications = true, + this.language = 'id', + this.ageRange, + }); + + factory UserPreferences.fromJson(Map json) { + return UserPreferences( + favoriteCategories: List.from(json['favoriteCategories'] ?? []), + allergies: List.from(json['allergies'] ?? []), + mood: json['mood'] as String? ?? 'neutral', + spiceLevel: json['spiceLevel'] as String? ?? 'medium', + dietaryRestriction: json['dietaryRestriction'] as String? ?? 'none', + budgetRange: json['budgetRange'] as int? ?? 50000, + enableNotifications: json['enableNotifications'] as bool? ?? true, + language: json['language'] as String? ?? 'id', + ageRange: json['ageRange'] as String?, + ); + } + + Map toJson() { + return { + 'favoriteCategories': favoriteCategories, + 'allergies': allergies, + 'mood': mood, + 'spiceLevel': spiceLevel, + 'dietaryRestriction': dietaryRestriction, + 'budgetRange': budgetRange, + 'enableNotifications': enableNotifications, + 'language': language, + 'ageRange': ageRange, + }; + } + + UserPreferences copyWith({ + List? favoriteCategories, + List? allergies, + String? mood, + String? spiceLevel, + String? dietaryRestriction, + int? budgetRange, + bool? enableNotifications, + String? language, + String? ageRange, + }) { + return UserPreferences( + favoriteCategories: favoriteCategories ?? this.favoriteCategories, + allergies: allergies ?? this.allergies, + mood: mood ?? this.mood, + spiceLevel: spiceLevel ?? this.spiceLevel, + dietaryRestriction: dietaryRestriction ?? this.dietaryRestriction, + budgetRange: budgetRange ?? this.budgetRange, + enableNotifications: enableNotifications ?? this.enableNotifications, + language: language ?? this.language, + ageRange: ageRange ?? this.ageRange, + ); + } + + String get preferencesDescription { + List descriptions = []; + + if (favoriteCategories.isNotEmpty) { + descriptions.add('Suka: ${favoriteCategories.join(', ')}'); + } + + if (allergies.isNotEmpty) { + descriptions.add('Alergi: ${allergies.join(', ')}'); + } + + descriptions.add('Mood: $mood'); + descriptions.add('Level pedas: $spiceLevel'); + + if (dietaryRestriction != 'none') { + descriptions.add('Diet: $dietaryRestriction'); + } + + descriptions.add('Budget: Rp ${budgetRange.toString()}'); + + return descriptions.join(', '); + } +} diff --git a/lib/models/user_model.g.dart b/lib/models/user_model.g.dart new file mode 100644 index 0000000..64776b6 --- /dev/null +++ b/lib/models/user_model.g.dart @@ -0,0 +1,138 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserModelAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + UserModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserModel( + id: fields[0] as String, + email: fields[1] as String, + displayName: fields[2] as String?, + photoUrl: fields[3] as String?, + phoneNumber: fields[7] as String?, + preferences: fields[4] as UserPreferences, + createdAt: fields[5] as DateTime, + updatedAt: fields[6] as DateTime, + isSetupComplete: fields[8] as bool, + nickname: fields[9] as String?, + gender: fields[10] as String?, + address: fields[11] as String?, + lastCheckinDate: fields[12] as DateTime?, + dailyPreferences: (fields[13] as Map?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, UserModel obj) { + writer + ..writeByte(14) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.email) + ..writeByte(2) + ..write(obj.displayName) + ..writeByte(3) + ..write(obj.photoUrl) + ..writeByte(4) + ..write(obj.preferences) + ..writeByte(5) + ..write(obj.createdAt) + ..writeByte(6) + ..write(obj.updatedAt) + ..writeByte(7) + ..write(obj.phoneNumber) + ..writeByte(8) + ..write(obj.isSetupComplete) + ..writeByte(9) + ..write(obj.nickname) + ..writeByte(10) + ..write(obj.gender) + ..writeByte(11) + ..write(obj.address) + ..writeByte(12) + ..write(obj.lastCheckinDate) + ..writeByte(13) + ..write(obj.dailyPreferences); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class UserPreferencesAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + UserPreferences read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return UserPreferences( + favoriteCategories: (fields[0] as List).cast(), + allergies: (fields[1] as List).cast(), + mood: fields[2] as String, + spiceLevel: fields[3] as String, + dietaryRestriction: fields[4] as String, + budgetRange: fields[5] as int, + enableNotifications: fields[6] as bool, + language: fields[7] as String, + ageRange: fields[8] as String?, + ); + } + + @override + void write(BinaryWriter writer, UserPreferences obj) { + writer + ..writeByte(9) + ..writeByte(0) + ..write(obj.favoriteCategories) + ..writeByte(1) + ..write(obj.allergies) + ..writeByte(2) + ..write(obj.mood) + ..writeByte(3) + ..write(obj.spiceLevel) + ..writeByte(4) + ..write(obj.dietaryRestriction) + ..writeByte(5) + ..write(obj.budgetRange) + ..writeByte(6) + ..write(obj.enableNotifications) + ..writeByte(7) + ..write(obj.language) + ..writeByte(8) + ..write(obj.ageRange); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is UserPreferencesAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/providers/amor_chat_provider.dart b/lib/providers/amor_chat_provider.dart new file mode 100644 index 0000000..a8d06a5 --- /dev/null +++ b/lib/providers/amor_chat_provider.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../services/firebase_service.dart'; + +// โ”€โ”€ Model โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class AmorChatEntry { + final String text; + final bool isUser; + final DateTime time; + + AmorChatEntry({ + required this.text, + required this.isUser, + required this.time, + }); + + Map toJson() => { + 'text': text, + 'isUser': isUser, + 'time': time.toIso8601String(), + }; + + factory AmorChatEntry.fromJson(Map json) => AmorChatEntry( + text: json['text'] as String, + isUser: json['isUser'] as bool, + time: DateTime.parse(json['time'] as String), + ); +} + +// โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class AmorChatState { + final List messages; + final bool isLoading; + final bool isLoadedFromFirestore; + + const AmorChatState({ + this.messages = const [], + this.isLoading = false, + this.isLoadedFromFirestore = false, + }); + + AmorChatState copyWith({ + List? messages, + bool? isLoading, + bool? isLoadedFromFirestore, + }) => + AmorChatState( + messages: messages ?? this.messages, + isLoading: isLoading ?? this.isLoading, + isLoadedFromFirestore: + isLoadedFromFirestore ?? this.isLoadedFromFirestore, + ); +} + +// โ”€โ”€ Notifier โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class AmorChatNotifier extends StateNotifier { + final FirebaseFirestore _db = FirebaseFirestore.instance; + static const String _chatCollection = 'amorChatHistory'; + static const int _maxMessages = 100; // batas simpan + + AmorChatNotifier() : super(const AmorChatState()); + + String? get _uid => FirebaseService().currentUser?.uid; + + // โ”€โ”€ Load riwayat dari Firestore โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future loadHistory() async { + if (state.isLoadedFromFirestore) return; // sudah dimuat, skip + final uid = _uid; + if (uid == null) return; + + try { + final doc = await _db.collection(_chatCollection).doc(uid).get(); + if (doc.exists && doc.data() != null) { + final raw = doc.data()!['messages'] as List? ?? []; + final msgs = raw + .map((e) => + AmorChatEntry.fromJson(Map.from(e as Map))) + .toList(); + state = state.copyWith( + messages: msgs, + isLoadedFromFirestore: true, + ); + } else { + state = state.copyWith(isLoadedFromFirestore: true); + } + } catch (e) { + debugPrint('AmorChat loadHistory error: $e'); + state = state.copyWith(isLoadedFromFirestore: true); + } + } + + // โ”€โ”€ Tambah pesan user โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + void addUserMessage(String text) { + final entry = AmorChatEntry( + text: text, + isUser: true, + time: DateTime.now(), + ); + state = state.copyWith(messages: [...state.messages, entry]); + } + + // โ”€โ”€ Tambah pesan AI & simpan ke Firestore โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future addAIMessage(String text) async { + final entry = AmorChatEntry( + text: text, + isUser: false, + time: DateTime.now(), + ); + final updated = [...state.messages, entry]; + state = state.copyWith(messages: updated); + await _saveToFirestore(updated); + } + + // โ”€โ”€ Set loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + void setLoading(bool v) => state = state.copyWith(isLoading: v); + + // โ”€โ”€ Hapus semua riwayat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future clearHistory() async { + state = state.copyWith(messages: []); + final uid = _uid; + if (uid == null) return; + try { + await _db.collection(_chatCollection).doc(uid).delete(); + } catch (e) { + debugPrint('AmorChat clearHistory error: $e'); + } + } + + // โ”€โ”€ Simpan ke Firestore โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future _saveToFirestore(List messages) async { + final uid = _uid; + if (uid == null) return; + try { + // Batasi ke _maxMessages terakhir agar tidak membengkak + final limited = messages.length > _maxMessages + ? messages.sublist(messages.length - _maxMessages) + : messages; + await _db.collection(_chatCollection).doc(uid).set({ + 'messages': limited.map((m) => m.toJson()).toList(), + 'updatedAt': DateTime.now().toIso8601String(), + }); + } catch (e) { + debugPrint('AmorChat _saveToFirestore error: $e'); + } + } + + // Kembalikan history dalam format yang dibutuhkan GeminiService + List> get aiHistory => state.messages + .map((m) => { + 'role': m.isUser ? 'user' : 'model', + 'text': m.text, + }) + .toList(); +} + +// โ”€โ”€ Provider โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +final amorChatProvider = + StateNotifierProvider( + (ref) => AmorChatNotifier(), +); diff --git a/lib/screens/auth/forgot_password_screen.dart b/lib/screens/auth/forgot_password_screen.dart new file mode 100644 index 0000000..b39ad5e --- /dev/null +++ b/lib/screens/auth/forgot_password_screen.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; +import '../../services/firebase_service.dart'; + +// Same colors as LoginScreen for consistency +const Color primaryBrown = Color(0xFF4A3C3C); +const Color lightBrown = Color(0xFF6B5B5B); +const Color headerGradientStart = Color(0xFF5D4E4E); +const Color headerGradientEnd = Color(0xFF8B7B7B); + +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _emailController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + Future _handleResetPassword() async { + if (_emailController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Silakan masukkan email Anda')), + ); + return; + } + + setState(() => _isLoading = true); + + try { + await FirebaseService().resetPassword(_emailController.text.trim()); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Email reset password telah dikirim'), + backgroundColor: AppTheme.success, + ), + ); + Navigator.pop(context); // Go back to login + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: AppTheme.error, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: primaryBrown), + onPressed: () => Navigator.pop(context), + ), + backgroundColor: Colors.white, + elevation: 0, + centerTitle: true, + title: const Text( + 'Lupa Password', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20), + // Illustration (Optional, using a placeholder icon or image if available) + Icon( + Icons.lock_reset_rounded, + size: 80, + color: primaryBrown.withOpacity(0.5), + ), + const SizedBox(height: 32), + const Text( + 'Reset Kata Sandi', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 24, + fontWeight: FontWeight.bold, + color: primaryBrown, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'Masukkan email yang terdaftar akun Anda. Kami akan mengirimkan link untuk mereset kata sandi Anda.', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: primaryBrown.withOpacity(0.6), + height: 1.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + + // Email Input + Builder( + builder: (context) { + return TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + onTap: () { + Future.delayed(const Duration(milliseconds: 300), () { + if (context.mounted) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + }, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: primaryBrown, + ), + decoration: InputDecoration( + labelText: 'Email', + hintText: 'nama@email.com', + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: primaryBrown.withOpacity(0.6), + ), + hintStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: primaryBrown.withOpacity(0.4), + ), + prefixIcon: Icon( + Icons.email_outlined, + color: primaryBrown.withOpacity(0.5), + ), + filled: true, + fillColor: Colors.grey[50], // Very light grey for input + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: primaryBrown.withOpacity(0.15), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: primaryBrown.withOpacity(0.15), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: primaryBrown.withOpacity(0.4), + ), + ), + ), + ); + }, + ), + const SizedBox(height: 32), + + // Send Button (Gradient) + Stack( + clipBehavior: Clip.none, + children: [ + // Shadow + Positioned( + top: -6, + left: 0, + right: -6, + bottom: 6, + child: Container( + decoration: BoxDecoration( + color: primaryBrown.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + // Button + Container( + height: 52, + width: double.infinity, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [lightBrown, headerGradientEnd], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: primaryBrown.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: _isLoading ? null : _handleResetPassword, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text( + 'Kirim Link Reset', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/auth/login_screen.dart b/lib/screens/auth/login_screen.dart new file mode 100644 index 0000000..b839ed8 --- /dev/null +++ b/lib/screens/auth/login_screen.dart @@ -0,0 +1,2326 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import '../../services/firebase_service.dart'; +import '../../services/emailjs_service.dart'; +import '../../services/fonnte_service.dart'; +import '../../services/google_signin_wrapper.dart'; +import '../../widgets/auth/verification_step_widget.dart'; +import '../../services/dialog_service.dart'; +import '../../widgets/common/scale_button.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../home/home_screen.dart'; +import '../onboarding/early_setup_screen.dart'; + +const Color primaryBrown = Color(0xFF4A3C3C); +const Color lightBrown = Color(0xFF6B5B5B); +const Color headerGradientStart = Color(0xFF5D4E4E); +const Color headerGradientEnd = Color(0xFF8B7B7B); + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + bool _isLoading = false; + bool _isGoogleLoading = false; + bool _obscurePassword = true; + AutovalidateMode _autoValidateMode = AutovalidateMode.disabled; + + // State for toggling between Login, Register, and Forgot Password + bool _isRegistering = false; + bool _isForgotPassword = false; + int _forgotPasswordStep = + 0; // 0=Email, 1=ChooseVerification, 2=VerifyOTP, 3=NewPassword, 4=Success + final List _codeControllers = List.generate( + 5, + (index) => TextEditingController(), + ); + int _registerStep = + 0; // 0=Profile, 1=ChooseVerification, 2=VerifyOTP, 3=Password, 4=Success + String _verificationMethod = 'email'; // 'email' or 'wa' + + // New Controllers for expanded registration + final _passwordRegisterController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + final _nameController = TextEditingController(); + final _phoneController = TextEditingController(); + + // Timer for verification code + Timer? _timer; + Timer? _errorTimer; // For reverting error state + int _remainingSeconds = 225; // 3 minutes 45 seconds + bool _isCodeError = false; // Track if code is incorrect + + // Password Validation State + bool _hasMinLength = false; + bool _hasUppercase = false; + bool _hasLowercase = false; + bool _hasNumber = false; + bool _passwordsMatch = false; + + // Email Validation State + String? _emailError; + + // Login Validation State + String? _loginEmailError; + String? _loginPasswordError; + + // Biodata Validation State + String? _nameError; + String? _phoneError; + + // Track if user attempted to submit (to show validation errors) + bool _showPasswordErrors = false; + + // Saved Google account for deferred Firebase sign-in + GoogleSignInAccount? _pendingGoogleAccount; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + // Dispose new controllers + _passwordRegisterController.dispose(); + _confirmPasswordController.dispose(); + _nameController.dispose(); + _phoneController.dispose(); + + for (var c in _codeControllers) c.dispose(); + _timer?.cancel(); + _errorTimer?.cancel(); + super.dispose(); + } + + void _startTimer() { + _timer?.cancel(); + setState(() => _remainingSeconds = 225); // Reset to 3:45 + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_remainingSeconds > 0) { + if (mounted) setState(() => _remainingSeconds--); + } else { + timer.cancel(); + } + }); + } + + String get _formattedTime { + final minutes = (_remainingSeconds / 60).floor(); + final seconds = _remainingSeconds % 60; + return '$minutes.${seconds.toString().padLeft(2, '0')}'; + } + + // Toggle between Login and Register + void _toggleAuthMode() { + setState(() { + _isRegistering = !_isRegistering; + _isForgotPassword = false; // Reset forgot password + _registerStep = 0; // Reset register step + _forgotPasswordStep = 0; + _formKey.currentState?.reset(); + _timer?.cancel(); // Cancel timer if switching modes + _errorTimer?.cancel(); + _isCodeError = false; + // Reset validation states + _emailError = null; + _hasMinLength = false; + _hasUppercase = false; + _hasLowercase = false; + _hasNumber = false; + _passwordsMatch = false; + _showPasswordErrors = false; + _loginEmailError = null; + _loginPasswordError = null; + _nameError = null; + _phoneError = null; + // Clear all controllers except login email + _passwordController.clear(); + _passwordRegisterController.clear(); + _confirmPasswordController.clear(); + _nameController.clear(); + _phoneController.clear(); + for (var c in _codeControllers) c.clear(); + }); + } + + void _toggleForgotPasswordMode() { + setState(() { + _isForgotPassword = !_isForgotPassword; + _isRegistering = false; + _forgotPasswordStep = 0; + _registerStep = 0; + _formKey.currentState?.reset(); + _timer?.cancel(); + _errorTimer?.cancel(); + _isCodeError = false; + // Reset validation states + _emailError = null; + _hasMinLength = false; + _hasUppercase = false; + _hasLowercase = false; + _hasNumber = false; + _passwordsMatch = false; + _showPasswordErrors = false; + _loginEmailError = null; + _loginPasswordError = null; + _nameError = null; + _phoneError = null; + // Clear all controllers except login email + _passwordController.clear(); + _passwordRegisterController.clear(); + _confirmPasswordController.clear(); + _nameController.clear(); + _phoneController.clear(); + for (var c in _codeControllers) c.clear(); + }); + } + + void _checkPasswordRequirements() { + final password = _passwordRegisterController.text; + final confirm = _confirmPasswordController.text; + + setState(() { + _hasMinLength = password.length >= 8; + _hasUppercase = password.contains(RegExp(r'[A-Z]')); + _hasLowercase = password.contains(RegExp(r'[a-z]')); + _hasNumber = password.contains(RegExp(r'[0-9]')); + _passwordsMatch = password.isNotEmpty && password == confirm; + }); + } + + String _getPasswordErrorText() { + List errors = []; + if (!_hasMinLength) errors.add('minimal 8 karakter'); + if (!_hasUppercase) errors.add('huruf besar (A-Z)'); + if (!_hasLowercase) errors.add('huruf kecil (a-z)'); + if (!_hasNumber) errors.add('angka (0-9)'); + + if (errors.isEmpty) return ''; + return 'Password harus mengandung ${errors.join(', ')}'; + } + + Future _showCancelConfirmationDialog() async { + String title = 'Batalkan Proses?'; + String content = _isForgotPassword + ? 'Apakah anda yakin ingin membatalkan reset password?' + : 'Apakah anda yakin ingin membatalkan registrasi?'; + + final confirm = await DialogService().showConfirmDialog( + title: title, + message: content, + confirmText: 'Ya, Batalkan', + cancelText: 'Tidak', + isDanger: true, + context: context, + ); + + if (confirm && mounted) { + setState(() { + _isForgotPassword = false; + _isRegistering = false; + _forgotPasswordStep = 0; + _registerStep = 0; + _timer?.cancel(); + _errorTimer?.cancel(); + _isCodeError = false; + _passwordController.clear(); + _passwordRegisterController.clear(); + _confirmPasswordController.clear(); + _nameController.clear(); + _phoneController.clear(); + for (var c in _codeControllers) c.clear(); + }); + } + } + + Future _signInWithEmail() async { + // Validate email + final email = _emailController.text.trim(); + if (email.isEmpty) { + setState(() => _loginEmailError = 'Email tidak boleh kosong'); + return; + } + if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email)) { + setState(() => _loginEmailError = 'Format email tidak valid'); + return; + } + + // Validate password + final password = _passwordController.text; + if (password.isEmpty) { + setState(() => _loginPasswordError = 'Password tidak boleh kosong'); + return; + } + if (password.length < 6) { + setState(() => _loginPasswordError = 'Password minimal 6 karakter'); + return; + } + + setState(() => _isLoading = true); + + try { + final firebaseService = FirebaseService(); + final userCredential = await firebaseService.signInWithEmail( + _emailController.text.trim().toLowerCase(), // Hindari huruf kapital dari keyboard + _passwordController.text.trim(), // Trim password untuk mencegah spasi tersembunyi + ); + + if (userCredential?.user != null && mounted) { + final userData = await firebaseService.getUserData( + userCredential!.user!.uid, + ); + + if (mounted) { + if (userData == null || !userData.isSetupComplete) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const EarlySetupScreen()), + ); + } else { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomeScreen()), + ); + } + } + } + } catch (e) { + if (mounted) { + DialogService().showError(e.toString()); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _handleRegister() async { + final firebaseService = FirebaseService(); + + // Step 0: Validate Profile (Name, Phone, Email) + if (_registerStep == 0) { + if (_nameController.text.trim().isEmpty) { + setState(() => _nameError = 'Nama Lengkap harus diisi'); + return; + } + if (_phoneController.text.trim().isEmpty) { + setState(() => _phoneError = 'Nomor Telepon harus diisi'); + return; + } + if (_phoneController.text.trim().length < 10) { + setState(() => _phoneError = 'Nomor telepon tidak valid'); + return; + } + if (_emailController.text.isEmpty) { + setState(() => _emailError = 'Email tidak boleh kosong'); + return; + } + if (!_emailController.text.contains('@')) { + setState(() => _emailError = 'Email tidak valid'); + return; + } + + // All valid -> go to Step 1 (Choose Verification Method) + setState(() => _registerStep = 1); + } + // Step 1 is handled by _sendVerificationOTP() + // + // Step 2: Verify OTP Code + else if (_registerStep == 2) { + String code = _codeControllers.map((c) => c.text).join(); + + if (code.length < 5) { + setState(() => _isCodeError = true); + return; + } + + setState(() => _isLoading = true); + + // Artificial delay to show loading state (UX) + await Future.delayed(const Duration(seconds: 2)); + + try { + bool isVerified; + + if (_verificationMethod == 'wa') { + isVerified = FonnteService.verifyOTP( + _phoneController.text.trim(), + code, + ); + } else { + isVerified = EmailJSService.verifyOTP( + _emailController.text.trim(), + code, + ); + } + + if (!isVerified) { + setState(() { + _isLoading = false; + _isCodeError = true; + }); + return; + } + + // OTP verified -> Show success dialog then go to Step 3 + if (mounted) { + setState(() => _isLoading = false); + _timer?.cancel(); + + await showGeneralDialog( + context: context, + barrierDismissible: false, + barrierLabel: "Success Dialog", + transitionDuration: const Duration(milliseconds: 400), + pageBuilder: (context, animation, secondaryAnimation) { + return _VerificationSuccessDialog( + onDismiss: () => Navigator.of(context).pop(), + ); + }, + transitionBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: CurvedAnimation( + parent: animation, + curve: Curves.easeOutBack, + ), + child: FadeTransition(opacity: animation, child: child), + ); + }, + ); + + if (mounted) { + setState(() { + _registerStep = 3; + }); + } + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + DialogService().showError('Error: ${e.toString()}'); + } + } + } + // Step 3: Password - Create Firebase Account + Firestore doc + else if (_registerStep == 3) { + _checkPasswordRequirements(); + + if (!_hasMinLength || + !_hasUppercase || + !_hasLowercase || + !_hasNumber || + !_passwordsMatch) { + setState(() => _showPasswordErrors = true); + return; + } + + setState(() => _isLoading = true); + + try { + if (_pendingGoogleAccount != null) { + // Google user -> Sign into Firebase now + debugPrint( + '๐Ÿ”ฅ OTP verified -> Now creating Firebase Auth for Google user', + ); + final wrapper = GoogleSignInWrapper.instance; + final userCredential = await wrapper.signInToFirebase( + _pendingGoogleAccount!, + ); + + if (userCredential?.user == null) { + throw Exception('Gagal masuk ke Firebase dengan akun Google'); + } + + // Link email/password + await firebaseService.linkEmailPassword( + _emailController.text.trim().toLowerCase(), + _passwordRegisterController.text, + ); + + // Create Firestore document with profile data from Step 0 + debugPrint('๐Ÿ“ Creating Firestore document for Google user...'); + await firebaseService.createUserDocument( + userCredential!.user!, + name: _nameController.text.trim(), + phone: _phoneController.text.trim(), + ); + debugPrint('โœ… Firestore document created for Google user'); + + _pendingGoogleAccount = null; + + setState(() { + _isLoading = false; + _registerStep = 4; + }); + } else { + // Email user -> Create new Firebase account + final credential = await firebaseService.registerWithEmail( + _emailController.text.trim().toLowerCase(), + _passwordRegisterController.text, + ); + + if (credential?.user != null) { + // Create Firestore document with profile data from Step 0 + debugPrint('๐Ÿ“ Creating Firestore document for email user...'); + await firebaseService.createUserDocument( + credential!.user!, + name: _nameController.text.trim(), + phone: _phoneController.text.trim(), + ); + debugPrint('โœ… Firestore document created for email user'); + + setState(() { + _isLoading = false; + _registerStep = 4; + }); + } else { + throw Exception('Gagal membuat akun'); + } + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + String errorMessage = e.toString(); + if (errorMessage.contains('email-already-in-use')) { + errorMessage = + 'Email sudah terdaftar. Silakan login atau gunakan email lain.'; + } else if (errorMessage.contains('provider-already-linked')) { + setState(() { + _registerStep = 4; + }); + return; + } + DialogService().showError('Gagal: $errorMessage'); + } + } + } + } + + /// Send verification OTP via chosen method (email or WhatsApp) + Future _sendVerificationOTP(String method) async { + setState(() { + _verificationMethod = method; + _isLoading = true; + }); + + try { + bool otpSent; + String targetDisplay; + + if (method == 'wa') { + otpSent = await FonnteService.sendOTP(_phoneController.text.trim()); + targetDisplay = _phoneController.text.trim(); + } else { + otpSent = await EmailJSService.sendOTP(_emailController.text.trim()); + targetDisplay = _emailController.text.trim(); + } + + if (otpSent) { + setState(() { + _isLoading = false; + _registerStep = 2; + }); + _startTimer(); + + if (mounted) { + final channel = method == 'wa' ? 'WhatsApp' : 'Email'; + DialogService().showInfo( + 'Kode verifikasi telah dikirim ke $channel ($targetDisplay)', + ); + } + } else { + throw Exception('Gagal mengirim kode verifikasi'); + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + DialogService().showError('Gagal: ${e.toString()}'); + } + } + } + + Future _resendCode() async { + setState(() => _isLoading = true); + + try { + bool otpSent; + + if (_verificationMethod == 'wa') { + otpSent = await FonnteService.sendOTP(_phoneController.text.trim()); + } else { + otpSent = await EmailJSService.sendOTP(_emailController.text.trim()); + } + + if (mounted) { + setState(() => _isLoading = false); + + if (otpSent) { + for (var c in _codeControllers) { + c.clear(); + } + _startTimer(); + final channel = _verificationMethod == 'wa' ? 'WhatsApp' : 'Email'; + DialogService().showSuccess( + 'Kode verifikasi baru telah dikirim ke $channel Anda', + ); + } else { + DialogService().showError('Gagal mengirim kode verifikasi'); + } + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + DialogService().showError('Gagal mengirim ulang: ${e.toString()}'); + } + } + } + + /// Send OTP for forgot password flow + Future _sendForgotPasswordOTP(String method) async { + setState(() { + _verificationMethod = method; + _isLoading = true; + }); + + try { + bool otpSent; + String targetDisplay; + + if (method == 'wa') { + // For WhatsApp, we need phone number - get from user data + final userData = await FirebaseService().getUserDataByEmail( + _emailController.text.trim(), + ); + if (userData?.phoneNumber == null) { + throw Exception( + 'Nomor WhatsApp tidak terdaftar. Gunakan verifikasi email.', + ); + } + otpSent = await FonnteService.sendOTP(userData!.phoneNumber!); + targetDisplay = userData.phoneNumber!; + } else { + otpSent = await EmailJSService.sendOTP(_emailController.text.trim()); + targetDisplay = _emailController.text.trim(); + } + + if (otpSent) { + setState(() { + _isLoading = false; + _forgotPasswordStep = 2; // Go to OTP verification + }); + _startTimer(); + + if (mounted) { + final channel = method == 'wa' ? 'WhatsApp' : 'Email'; + DialogService().showInfo( + 'Kode verifikasi telah dikirim ke $channel ($targetDisplay)', + ); + } + } else { + throw Exception('Gagal mengirim kode verifikasi'); + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + DialogService().showError('Gagal: ${e.toString()}'); + } + } + } + + /// Verify OTP for forgot password + Future _handleForgotPasswordVerification() async { + final code = _codeControllers.map((c) => c.text).join(); + + if (code.length != 5) { + DialogService().showError('Masukkan kode 5 digit'); + return; + } + + setState(() => _isLoading = true); + + try { + bool isVerified; + + if (_verificationMethod == 'wa') { + final userData = await FirebaseService().getUserDataByEmail( + _emailController.text.trim(), + ); + isVerified = FonnteService.verifyOTP(userData?.phoneNumber ?? '', code); + } else { + isVerified = EmailJSService.verifyOTP( + _emailController.text.trim(), + code, + ); + } + + if (isVerified) { + setState(() { + _isLoading = false; + _forgotPasswordStep = 3; // Go to new password step + }); + _timer?.cancel(); + } else { + setState(() { + _isLoading = false; + _isCodeError = true; + }); + + // Auto-reset error state after 3 seconds + _errorTimer?.cancel(); + _errorTimer = Timer(const Duration(seconds: 3), () { + if (mounted) { + setState(() => _isCodeError = false); + } + }); + + DialogService().showError('Kode verifikasi salah'); + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + DialogService().showError('Verifikasi gagal: ${e.toString()}'); + } + } + } + + /// Resend OTP for forgot password + Future _resendForgotPasswordCode() async { + setState(() => _isLoading = true); + + try { + bool otpSent; + + if (_verificationMethod == 'wa') { + final userData = await FirebaseService().getUserDataByEmail( + _emailController.text.trim(), + ); + otpSent = await FonnteService.sendOTP(userData?.phoneNumber ?? ''); + } else { + otpSent = await EmailJSService.sendOTP(_emailController.text.trim()); + } + + if (mounted) { + setState(() => _isLoading = false); + + if (otpSent) { + for (var c in _codeControllers) { + c.clear(); + } + _startTimer(); + final channel = _verificationMethod == 'wa' ? 'WhatsApp' : 'Email'; + DialogService().showSuccess( + 'Kode verifikasi baru telah dikirim ke $channel Anda', + ); + } else { + DialogService().showError('Gagal mengirim kode verifikasi'); + } + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + DialogService().showError('Gagal mengirim ulang: ${e.toString()}'); + } + } + } + + /// Handle password reset after OTP verification + Future _handlePasswordReset() async { + setState(() => _isLoading = true); + + try { + final email = _emailController.text.trim().toLowerCase(); + + // Kirim email reset password via Firebase โ€” cara resmi & benar + await FirebaseService().resetPassword(email); + + if (mounted) { + setState(() { + _isLoading = false; + _forgotPasswordStep = 4; // Tampilkan halaman sukses + }); + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + DialogService().showError('Gagal mereset password: ${e.toString()}'); + } + } + } + + Future _signInWithGoogle() async { + setState(() => _isGoogleLoading = true); + + try { + final firebaseService = FirebaseService(); + final wrapper = GoogleSignInWrapper.instance; + + // Step 1: Get Google account info - ONE picker popup only + final googleAccount = await wrapper.getGoogleAccount(); + if (googleAccount == null) { + if (mounted) setState(() => _isGoogleLoading = false); + return; // User canceled + } + + debugPrint('๐Ÿ“ง Google account selected: ${googleAccount.email}'); + + // Step 2: Check if email is already registered in Firestore BEFORE signing into Firebase + bool isRegistered = await firebaseService.checkEmailExists(googleAccount.email); + + if (mounted) { + if (isRegistered) { + // RETURNING USER -> Safe to sign in to Firebase + final result = await wrapper.signInToFirebase(googleAccount); + if (result?.user == null) { + if (mounted) setState(() => _isGoogleLoading = false); + return; + } + + final userData = await firebaseService.getUserData(result!.user!.uid); + + if (mounted) { + if (userData == null || !userData.isSetupComplete) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const EarlySetupScreen(), + ), + ); + } else { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomeScreen()), + ); + } + } + } else { + // NEW USER -> DO NOT register automatically. Redirect to Register form. + debugPrint('๐Ÿ†• Unregistered Google user -> Redirecting to Registration Form'); + + // Save Google account for later (after OTP verification & form completion) + _pendingGoogleAccount = googleAccount; + _emailController.text = googleAccount.email; + _nameController.text = googleAccount.displayName ?? ''; + + setState(() { + _isRegistering = true; + _isForgotPassword = false; + _registerStep = 0; + _isGoogleLoading = false; + }); + + DialogService().showSuccess( + 'Silahkan lengkapi registrasi terlebih dahulu.', + ); + } + } + } catch (e) { + if (mounted) { + setState(() => _isGoogleLoading = false); + DialogService().showError('Login gagal: ${e.toString()}'); + } + } + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return Scaffold( + backgroundColor: Colors.white, + body: SingleChildScrollView( + child: Stack( + children: [ + // Main Column with Header and Form + Column( + children: [ + // Header Section with Overlay Image + _buildHeader(screenHeight), + + // Spacer for form overlap + const SizedBox(height: 0), + ], + ), + + // Visible curved shadow element behind card (white transparent) + Positioned( + top: screenHeight * 0.30 - 45, + left: 20, + right: 20, + child: Container( + height: 50, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.4), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + ), + ), + + // Form Section (overlapping the header) + Column( + children: [ + SizedBox(height: screenHeight * 0.30 - 24), + _buildFormSection(), + ], + ), + ], + ), + ), + ); + } + + Widget _buildHeader(double screenHeight) { + return Container( + height: screenHeight * 0.30, + width: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [headerGradientStart, headerGradientEnd], + ), + ), + child: Stack( + children: [ + // Overlay Image - positioned lower and centered + Positioned( + top: 20, + left: 0, + right: 0, + bottom: -20, + child: Opacity( + opacity: 0.30, + child: Image.asset( + 'assets/images/overlayimage.png', + fit: BoxFit.contain, + alignment: Alignment.center, + ), + ), + ), + + // Text Content with Shadows + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 30), + + // App Name "Amori" - with shadow + Text( + 'Amori', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 48, + fontWeight: FontWeight.w600, + color: Colors.white, + letterSpacing: 1, + height: 1.1, + shadows: [ + Shadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + ), + + // Subtitle "Assistant" - with shadow + Text( + 'Assistant', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 20, + fontWeight: FontWeight.w300, + color: Colors.white.withOpacity(0.9), + letterSpacing: 3, + shadows: [ + Shadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildFormSection() { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 20, + offset: const Offset(0, -10), + spreadRadius: 0, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 24), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (Widget child, Animation animation) { + return FadeTransition(opacity: animation, child: child); + }, + child: Column( + key: ValueKey( + _isForgotPassword + ? 'forgot_pass' + : (_isRegistering ? 'reg_$_registerStep' : 'login'), + ), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Back button for Register/Forgot Password modes + if (_isRegistering || _isForgotPassword) + Align( + alignment: Alignment.centerLeft, + child: IconButton( + icon: const Icon(Icons.arrow_back, color: primaryBrown), + onPressed: () => _showCancelConfirmationDialog(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ), + if (_isRegistering || _isForgotPassword) + const SizedBox(height: 8), + // Welcome Text with Toggle Logic - Show for all steps except success + if (!(_isRegistering && _registerStep == 4) && + !(_isForgotPassword && _forgotPasswordStep == 3)) ...[ + Text( + _isForgotPassword + ? (_forgotPasswordStep == 0 + ? 'Lupa Password?' + : (_forgotPasswordStep == 1 + ? 'Verifikasi Email' + : 'Reset Password')) + : (_isRegistering + ? (_registerStep == 0 + ? 'Lengkapi Profil' + : (_registerStep == 1 + ? 'Pilih Verifikasi' + : (_registerStep == 2 + ? 'Verifikasi Kode' + : 'Buat Kata Sandi'))) + : 'Selamat Datang'), + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 28, + fontWeight: FontWeight.w700, + color: primaryBrown, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _isForgotPassword + ? (_forgotPasswordStep == 0 + ? 'Masukan email terdaftar untuk reset password' + : (_forgotPasswordStep == 1 + ? 'Silahkan masukan kode verifikasi yang dikirimkan ke email' + : 'Masukan password baru untuk melanjutkan')) + : (_isRegistering + ? (_registerStep == 0 + ? 'Lengkapi informasi profil untuk registrasi' + : (_registerStep == 1 + ? 'Pilih metode verifikasi yang Anda inginkan' + : (_registerStep == 2 + ? 'Masukkan kode verifikasi yang telah dikirim' + : 'Buat password yang kuat untuk akun anda'))) + : 'Masukan Detail Login Kamu'), + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: primaryBrown.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + ], + + const SizedBox(height: 32), + + // Form Content Toggle + // Form Content Toggle + if (_isForgotPassword) + _buildForgotPasswordContent() + else if (_isRegistering) + _buildRegisterContent() + else + _buildLoginContent(), + + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } + + Widget _buildLoginContent() { + return Form( + key: _formKey, + autovalidateMode: _autoValidateMode, + child: Column( + children: + [ + // Email Field + _buildInputField( + controller: _emailController, + label: 'Alamat Email', + hintText: 'Masukkan email anda', + keyboardType: TextInputType.emailAddress, + onChanged: (_) { + if (_loginEmailError != null) { + setState(() => _loginEmailError = null); + } + }, + errorText: _loginEmailError, + ), + const SizedBox(height: 16), + + // Password Field + _buildInputField( + controller: _passwordController, + label: 'Kata Sandi', + hintText: 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข', + obscureText: _obscurePassword, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: primaryBrown.withOpacity(0.5), + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + onChanged: (_) { + if (_loginPasswordError != null) { + setState(() => _loginPasswordError = null); + } + }, + errorText: _loginPasswordError, + ), + const SizedBox(height: 30), + + // Login Button + _buildGradientButton( + text: 'Login', + onPressed: _isLoading ? null : _signInWithEmail, + isLoading: _isLoading, + ), + const SizedBox(height: 16), + + // Toggle to Register using Outline Button Style + _buildOutlinedButton( + text: 'Register', + onPressed: _toggleAuthMode, + ), + const SizedBox(height: 16), + + // Forgot Password + // Forgot Password + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _toggleForgotPasswordMode, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + foregroundColor: primaryBrown.withOpacity(0.7), + overlayColor: primaryBrown.withOpacity(0.1), + ), + child: Text( + 'Lupa Password ?', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: primaryBrown.withOpacity(0.7), + ), + ), + ), + ), + const SizedBox(height: 24), + + // Divider with text + Row( + children: [ + Expanded( + child: Divider(color: primaryBrown.withOpacity(0.2)), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Atau Login Menggunakan', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: primaryBrown.withOpacity(0.5), + ), + ), + ), + Expanded( + child: Divider(color: primaryBrown.withOpacity(0.2)), + ), + ], + ), + const SizedBox(height: 24), + _buildGoogleButton(), + ] + .animate(interval: 50.ms) + .fadeIn(duration: 300.ms) + .slideY( + begin: 0.1, + end: 0, + duration: 300.ms, + curve: Curves.easeOut, + ), + ), + ); + } + + Widget _buildRegisterContent() { + return Column( + children: [ + if (_registerStep == 0) ...[ + // Step 0: Profile - Name, Phone, Email + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Image.asset( + 'assets/images/registerletter5.gif', + height: 180, + fit: BoxFit.contain, + ), + ), + _buildInputField( + controller: _nameController, + label: 'Nama Lengkap', + hintText: 'Masukkan nama lengkap anda', + onChanged: (_) { + if (_nameError != null) { + setState(() => _nameError = null); + } + }, + errorText: _nameError, + ), + const SizedBox(height: 16), + _buildInputField( + controller: _phoneController, + label: 'Nomor Telepon', + hintText: 'Contoh: 081234567890', + keyboardType: TextInputType.phone, + onChanged: (_) { + if (_phoneError != null) { + setState(() => _phoneError = null); + } + }, + errorText: _phoneError, + ), + const SizedBox(height: 16), + _buildInputField( + controller: _emailController, + label: 'Alamat Email', + hintText: 'Masukkan email anda', + keyboardType: TextInputType.emailAddress, + onChanged: (_) { + if (_emailError != null) { + setState(() => _emailError = null); + } + }, + errorText: _emailError, + ), + const SizedBox(height: 20), + _buildGradientButton( + text: 'Lanjutkan', + onPressed: _isLoading ? null : _handleRegister, + isLoading: _isLoading, + ), + ] else if (_registerStep == 1) ...[ + // Step 1: Choose Verification Method + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Image.asset( + 'assets/images/registerletter.gif', + height: 180, + fit: BoxFit.contain, + ), + ), + // Email verification button + _buildVerificationMethodButton( + icon: Icons.email_outlined, + title: 'Verifikasi via Email', + subtitle: _emailController.text.trim(), + onTap: _isLoading ? null : () => _sendVerificationOTP('email'), + ), + const SizedBox(height: 16), + // WhatsApp verification button + _buildVerificationMethodButton( + icon: Icons.chat_outlined, + title: 'Verifikasi via WhatsApp', + subtitle: _phoneController.text.trim(), + onTap: _isLoading ? null : () => _sendVerificationOTP('wa'), + isWhatsApp: true, + ), + if (_isLoading) ...[ + const SizedBox(height: 24), + const Center( + child: CircularProgressIndicator( + color: primaryBrown, + strokeWidth: 2, + ), + ), + ], + ] else if (_registerStep == 2) ...[ + // Step 2: Verification OTP - Using shared widget + VerificationStepWidget( + codeControllers: _codeControllers, + isError: _isCodeError, + onErrorReset: () => setState(() => _isCodeError = false), + remainingSeconds: _remainingSeconds, + formattedTime: _formattedTime, + isLoading: _isLoading, + onVerify: _handleRegister, + onResend: _resendCode, + onChangeEmail: () => setState(() => _registerStep = 1), + buttonBuilder: (text, onPressed, isLoading) => _buildGradientButton( + text: text, + onPressed: onPressed, + isLoading: isLoading, + ), + ), + ] else if (_registerStep == 3) ...[ + // Step 3: Create Password + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Image.asset( + 'assets/images/registerletter4.gif', + height: 180, + fit: BoxFit.contain, + ), + ), + _buildInputField( + controller: _passwordRegisterController, + label: 'Buat Kata Sandi', + hintText: 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข', + obscureText: _obscurePassword, + onChanged: (_) => _checkPasswordRequirements(), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: primaryBrown.withOpacity(0.5), + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + errorText: + (_showPasswordErrors || + _passwordRegisterController.text.isNotEmpty) && + (!_hasMinLength || + !_hasUppercase || + !_hasLowercase || + !_hasNumber) + ? _getPasswordErrorText() + : null, + ), + const SizedBox(height: 8), + // Password Requirements + Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPasswordRequirementRow( + 'Minimal 8 karakter', + _hasMinLength, + ), + const SizedBox(height: 6), + _buildPasswordRequirementRow( + 'Huruf besar (A-Z)', + _hasUppercase, + ), + const SizedBox(height: 6), + _buildPasswordRequirementRow( + 'Huruf kecil (a-z)', + _hasLowercase, + ), + const SizedBox(height: 6), + _buildPasswordRequirementRow('Angka (0-9)', _hasNumber), + ], + ), + ), + + const SizedBox(height: 16), + _buildInputField( + controller: _confirmPasswordController, + label: 'Konfirmasi Kata Sandi', + hintText: 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข', + obscureText: _obscurePassword, + onChanged: (_) { + setState(() { + _passwordsMatch = + _passwordRegisterController.text == + _confirmPasswordController.text; + }); + }, + errorText: _showPasswordErrors && !_passwordsMatch + ? 'Kata sandi tidak cocok' + : null, + ), + const SizedBox(height: 20), + _buildGradientButton( + text: 'Daftar Sekarang', + onPressed: _isLoading ? null : _handleRegister, + isLoading: _isLoading, + ), + ] else ...[ + // Step 4: Success + const SizedBox(height: 24), + Image.asset( + 'assets/images/registerletter3.gif', + height: 180, + fit: BoxFit.contain, + ), + const SizedBox(height: 24), + const Text( + 'Registrasi Berhasil', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 24, + fontWeight: FontWeight.bold, + color: primaryBrown, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Akun anda telah berhasil dibuat.\nSilahkan login untuk melanjutkan.', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: primaryBrown.withOpacity(0.7), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + _buildGradientButton( + text: 'Lanjut ke Login', + onPressed: _toggleAuthMode, + ), + ], + + // Back to Login Link (Only for Steps 0-3) + if (_registerStep != 4) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: _toggleAuthMode, + child: const Text( + 'Kembali ke Login', + style: TextStyle(color: primaryBrown, fontFamily: 'Poppins'), + ), + ), + ], + ], + ); + } + + /// Build a verification method selection button + Widget _buildVerificationMethodButton({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback? onTap, + bool isWhatsApp = false, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: primaryBrown.withOpacity(0.15)), + boxShadow: [ + BoxShadow( + color: primaryBrown.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: isWhatsApp + ? const Color(0xFF25D366).withOpacity(0.1) + : primaryBrown.withOpacity(0.08), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + icon, + color: isWhatsApp ? const Color(0xFF25D366) : primaryBrown, + size: 26, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 15, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: primaryBrown.withOpacity(0.5), + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + color: primaryBrown.withOpacity(0.3), + size: 18, + ), + ], + ), + ), + ), + ); + } + + Widget _buildInputField({ + required TextEditingController controller, + required String label, + String? hintText, + bool obscureText = false, + TextInputType? keyboardType, + Widget? suffixIcon, + String? Function(String?)? validator, + void Function(String)? onChanged, + String? errorText, + }) { + final bool hasError = errorText != null && errorText.isNotEmpty; + + return Builder( + builder: (context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + onChanged: onChanged, + onTap: () { + Future.delayed(const Duration(milliseconds: 300), () { + if (context.mounted) { + Scrollable.ensureVisible( + context, + alignment: 0.45, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + }, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: primaryBrown, + ), + decoration: InputDecoration( + labelText: label, + hintText: hintText, + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: hasError ? Colors.red : primaryBrown.withOpacity(0.6), + ), + hintStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: primaryBrown.withOpacity(0.4), + ), + suffixIcon: suffixIcon, + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: hasError + ? Colors.red + : primaryBrown.withOpacity(0.15), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: hasError + ? Colors.red + : primaryBrown.withOpacity(0.15), + width: hasError ? 1.5 : 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: hasError + ? Colors.red + : primaryBrown.withOpacity(0.4), + width: hasError ? 1.5 : 1, + ), + ), + ), + validator: validator, + ), + if (hasError) ...[ + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + errorText, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + color: Colors.red, + ), + ), + ), + ], + ], + ); + }, + ); + } + + Widget _buildGradientButton({ + required String text, + VoidCallback? onPressed, + bool isLoading = false, + }) { + return ScaleButton( + onPressed: isLoading ? null : onPressed, + child: Container( + width: double.infinity, + height: 52, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [lightBrown, headerGradientEnd], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: primaryBrown.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: isLoading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + foregroundColor: Colors.white, + overlayColor: Colors.white.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : Text( + text, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ); + } + + Widget _buildOutlinedButton({required String text, VoidCallback? onPressed}) { + return Container( + width: double.infinity, + height: 52, + decoration: BoxDecoration( + color: const Color(0xFFD9D9D9), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: primaryBrown.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: primaryBrown, + overlayColor: primaryBrown.withOpacity(0.15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + text, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: primaryBrown.withOpacity(0.85), + ), + ), + ), + ); + } + + Widget _buildGoogleButton() { + return Stack( + clipBehavior: Clip.none, + children: [ + // Shadow behind button (offset to top) with outline + Positioned( + top: -6, + left: 0, + right: -6, + bottom: 6, + child: Container( + width: 180, + height: 48, + decoration: BoxDecoration( + color: primaryBrown.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: primaryBrown.withOpacity(0.5), + width: 0.5, + ), + ), + ), + ), + // Main button + Container( + width: 180, + height: 48, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: primaryBrown.withOpacity(0.5), + width: 0.5, + ), + ), + child: TextButton( + onPressed: _isGoogleLoading ? null : _signInWithGoogle, + style: TextButton.styleFrom( + foregroundColor: primaryBrown, + overlayColor: primaryBrown.withOpacity(0.1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Google Icon from reliable CDN with smooth fade-in + Image.network( + 'https://developers.google.com/identity/images/g-logo.png', + width: 20, + height: 20, + gaplessPlayback: true, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) return child; + return AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: frame == null ? 0 : 1, + child: child, + ); + }, + errorBuilder: (context, error, stackTrace) { + // Fallback to colored G if network fails + return Container( + width: 20, + height: 20, + decoration: BoxDecoration( + border: Border.all( + color: const Color(0xFF4285F4), + width: 2, + ), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Text( + 'G', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF4285F4), + ), + ), + ), + ); + }, + ), + const SizedBox(width: 12), + Text( + 'Google', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w500, + color: primaryBrown.withOpacity(0.85), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildForgotPasswordContent() { + switch (_forgotPasswordStep) { + case 0: + return _buildForgotEmailStep(); + case 1: + return _buildForgotChooseVerificationStep(); + case 2: + return _buildForgotVerifyOTPStep(); + case 3: + return _buildForgotNewPasswordStep(); + case 4: + return _buildForgotSuccessStep(); + default: + return _buildForgotEmailStep(); + } + } + + Widget _buildForgotEmailStep() { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Image.asset( + 'assets/images/registerletter.gif', + height: 180, + fit: BoxFit.contain, + ), + ), + _buildInputField( + controller: _emailController, + label: 'E-mail', + hintText: 'Masukan email terdaftar', + keyboardType: TextInputType.emailAddress, + onChanged: (_) { + if (_emailError != null) { + setState(() => _emailError = null); + } + }, + errorText: _emailError, + ), + const SizedBox(height: 30), + _buildGradientButton( + text: 'Lanjutkan', + onPressed: _isLoading + ? null + : () async { + if (_emailController.text.trim().isEmpty) { + setState(() => _emailError = 'Email tidak boleh kosong'); + return; + } + if (!RegExp( + r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$', + ).hasMatch(_emailController.text.trim())) { + setState(() => _emailError = 'Email tidak valid'); + return; + } + + setState(() => _isLoading = true); + + try { + // Check if email exists in Firebase + final email = _emailController.text.trim(); + final userExists = await FirebaseService().checkEmailExists( + email, + ); + + if (!userExists) { + if (mounted) { + setState(() => _isLoading = false); + DialogService().showError( + 'Email tidak terdaftar. Silakan daftar terlebih dahulu.', + ); + } + return; + } + + if (mounted) { + setState(() { + _isLoading = false; + _forgotPasswordStep = 1; // Go to choose verification + }); + } + } catch (e) { + setState(() => _isLoading = false); + if (mounted) { + DialogService().showError( + 'Terjadi kesalahan: ${e.toString()}', + ); + } + } + }, + isLoading: _isLoading, + ), + const SizedBox(height: 16), + _buildOutlinedButton( + text: 'Kembali ke Login', + onPressed: _toggleForgotPasswordMode, + ), + ], + ); + } + + Widget _buildForgotChooseVerificationStep() { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Image.asset( + 'assets/images/registerletter2.gif', + height: 180, + fit: BoxFit.contain, + ), + ), + // Email verification button + _buildVerificationMethodButton( + icon: Icons.email_outlined, + title: 'Verifikasi via Email', + subtitle: _emailController.text.trim(), + onTap: _isLoading ? null : () => _sendForgotPasswordOTP('email'), + ), + const SizedBox(height: 16), + // WhatsApp verification button (optional - if user has phone) + _buildVerificationMethodButton( + icon: Icons.chat_outlined, + title: 'Verifikasi via WhatsApp', + subtitle: 'Kirim kode ke WhatsApp', + onTap: _isLoading ? null : () => _sendForgotPasswordOTP('wa'), + isWhatsApp: true, + ), + if (_isLoading) ...[ + const SizedBox(height: 24), + const Center( + child: CircularProgressIndicator( + color: primaryBrown, + strokeWidth: 2, + ), + ), + ], + const SizedBox(height: 16), + _buildOutlinedButton( + text: 'Kembali', + onPressed: () => setState(() => _forgotPasswordStep = 0), + ), + ], + ); + } + + Widget _buildForgotVerifyOTPStep() { + return VerificationStepWidget( + codeControllers: _codeControllers, + isError: _isCodeError, + onErrorReset: () => setState(() => _isCodeError = false), + remainingSeconds: _remainingSeconds, + formattedTime: _formattedTime, + isLoading: _isLoading, + onVerify: _handleForgotPasswordVerification, + onResend: _resendForgotPasswordCode, + onChangeEmail: () => setState(() => _forgotPasswordStep = 1), + buttonBuilder: (text, onPressed, isLoading) => _buildGradientButton( + text: text, + onPressed: onPressed, + isLoading: isLoading, + ), + ); + } + + Widget _buildForgotNewPasswordStep() { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Image.asset( + 'assets/images/registerletter4.gif', + height: 180, + fit: BoxFit.contain, + ), + ), + _buildInputField( + controller: _passwordRegisterController, + label: 'Password Baru', + hintText: 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข', + obscureText: _obscurePassword, + onChanged: (_) => _checkPasswordRequirements(), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: primaryBrown.withOpacity(0.5), + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + errorText: + (_showPasswordErrors || + _passwordRegisterController.text.isNotEmpty) && + (!_hasMinLength || + !_hasUppercase || + !_hasLowercase || + !_hasNumber) + ? _getPasswordErrorText() + : null, + ), + const SizedBox(height: 8), + // Password Requirements + Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPasswordRequirementRow('Minimal 8 karakter', _hasMinLength), + const SizedBox(height: 6), + _buildPasswordRequirementRow('Huruf besar (A-Z)', _hasUppercase), + const SizedBox(height: 6), + _buildPasswordRequirementRow('Huruf kecil (a-z)', _hasLowercase), + const SizedBox(height: 6), + _buildPasswordRequirementRow('Angka (0-9)', _hasNumber), + ], + ), + ), + const SizedBox(height: 16), + _buildInputField( + controller: _confirmPasswordController, + label: 'Konfirmasi Password', + hintText: 'โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข', + obscureText: _obscurePassword, + onChanged: (_) => _checkPasswordRequirements(), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + color: primaryBrown.withOpacity(0.5), + ), + onPressed: () => + setState(() => _obscurePassword = !_obscurePassword), + ), + errorText: + _confirmPasswordController.text.isNotEmpty && !_passwordsMatch + ? 'Password tidak cocok' + : null, + ), + const SizedBox(height: 30), + _buildGradientButton( + text: 'Reset Password', + onPressed: + (_isLoading || + !_hasMinLength || + !_hasUppercase || + !_hasLowercase || + !_hasNumber || + !_passwordsMatch) + ? null + : _handlePasswordReset, + isLoading: _isLoading, + ), + const SizedBox(height: 16), + _buildOutlinedButton( + text: 'Kembali', + onPressed: () => setState(() => _forgotPasswordStep = 2), + ), + ], + ); + } + + Widget _buildForgotSuccessStep() { + return Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/images/registerletter3.gif', + height: 150, + fit: BoxFit.contain, + ), + const SizedBox(height: 32), + const Text( + 'Email Reset Password Terkirim!', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 20, + fontWeight: FontWeight.bold, + color: primaryBrown, + ), + ), + const SizedBox(height: 8), + const Text( + 'Link reset password telah dikirim ke email Anda.\nBuka email โ†’ klik link โ†’ buat password baru โ†’ lalu login.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: Colors.grey, + ), + ), + const SizedBox(height: 40), // Spacer before button + _buildGradientButton( + text: 'Lanjut ke Login', + onPressed: () { + _toggleForgotPasswordMode(); + }, + ), + ], + ); + } + + Widget _buildPasswordRequirementRow(String text, bool met) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: met ? Colors.green : Colors.grey.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Icon( + Icons.check, + size: 10, + color: met ? Colors.white : Colors.grey, + ), + ), + const SizedBox(width: 8), + Text( + text, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: met ? Colors.green : Colors.grey, + fontWeight: met ? FontWeight.w500 : FontWeight.normal, + ), + ), + ], + ); + } +} + +class _VerificationSuccessDialog extends StatefulWidget { + final VoidCallback onDismiss; + + const _VerificationSuccessDialog({required this.onDismiss}); + + @override + State<_VerificationSuccessDialog> createState() => + _VerificationSuccessDialogState(); +} + +class _VerificationSuccessDialogState + extends State<_VerificationSuccessDialog> { + int _countdown = 5; + Timer? _timer; + + @override + void initState() { + super.initState(); + _startCountdown(); + } + + void _startCountdown() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + if (_countdown > 1) { + _countdown--; + } else { + timer.cancel(); + widget.onDismiss(); + } + }); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + contentPadding: const EdgeInsets.all(24), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/registerletter3.gif', + height: 150, + fit: BoxFit.contain, + ), + const SizedBox(height: 16), + const Text( + 'Email Berhasil Diverifikasi!', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.bold, + fontSize: 18, + color: primaryBrown, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Silahkan melanjutkan proses registrasi.', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + // Custom Gradient Button + Stack( + clipBehavior: Clip.none, + children: [ + // Shadow behind button (offset to top) + Positioned( + top: -6, + left: 0, + right: -6, + bottom: 6, + child: Container( + width: double.infinity, + height: 45, + decoration: BoxDecoration( + color: primaryBrown.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + ), + ), + ), + // Main button + Container( + width: double.infinity, + height: 45, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [lightBrown, headerGradientEnd], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: primaryBrown.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: widget.onDismiss, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + foregroundColor: Colors.white, + overlayColor: Colors.white.withOpacity(0.2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Lanjut isi Password', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 32), + Text( + 'Otomatis lanjut dalam $_countdown detik', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: Colors.grey, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/screens/camera/camera_screen.dart b/lib/screens/camera/camera_screen.dart new file mode 100644 index 0000000..5976f03 --- /dev/null +++ b/lib/screens/camera/camera_screen.dart @@ -0,0 +1,370 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/user_model.dart'; +import '../../services/ocr_service.dart'; +import '../../services/gemini_service.dart'; +import '../../services/firebase_service.dart'; +import '../../models/recommendation_model.dart'; + +import '../recommendation/recommendation_screen.dart'; + +class CameraScreen extends ConsumerStatefulWidget { + /// Satu atau lebih path foto menu (maks 3). + final List imagePaths; + final RecommendationModel? previousScan; + + const CameraScreen({super.key, required this.imagePaths, this.previousScan}); + + @override + ConsumerState createState() => _CameraScreenState(); +} + +class _CameraScreenState extends ConsumerState { + bool _isProcessing = false; + double _progress = 0.0; + + // Cycling loading text + int _cycleIndex = 0; + Timer? _cycleTimer; + + static const _cycleMessages = [ + ( + title: 'Tunggu Sebentar', + subtitle: 'Biarkan AI Memberikanmu Rekomendasi Terbaiknya', + ), + (title: 'Sebentar Lagi', subtitle: 'Ini Membutuhkan Waktu Sebentar Lagi'), + ]; + + @override + void initState() { + super.initState(); + _processImage(); + _cycleTimer = Timer.periodic(const Duration(seconds: 3), (_) { + if (mounted) { + setState(() { + _cycleIndex = (_cycleIndex + 1) % _cycleMessages.length; + }); + } + }); + } + + @override + void dispose() { + _cycleTimer?.cancel(); + super.dispose(); + } + + Future _processImage() async { + setState(() { + _isProcessing = true; + _progress = 0.1; + }); + + try { + // Step 1: OCR โ€“ gabungkan teks dari semua foto (Atau ambil dari riwayat) + setState(() => _progress = 0.3); + + final allMenuItems = []; + String combinedText = ''; + + if (widget.previousScan != null) { + // Lewati OCR, gunakan data dari riwayat sebelumnya + combinedText = widget.previousScan!.originalMenuText; + for (var rec in widget.previousScan!.recommendations) { + allMenuItems.add(MenuItemOCR( + name: rec.menuName, + price: rec.price, + description: rec.description, + category: rec.category, + )); + } + } else { + final ocrService = OCRService(); + + for (final path in widget.imagePaths) { + final ocrResult = await ocrService.extractTextFromImage(path); + if (ocrResult.isSuccess) { + allMenuItems.addAll(ocrResult.menuItems); + if (combinedText.isNotEmpty) combinedText += '\n'; + combinedText += ocrResult.processedText; + } + } + + // Validasi setidaknya ada 1 item menu + if (allMenuItems.isEmpty) { + // Fallback: gunakan hasil OCR foto pertama walaupun tidak ideal + final firstResult = await ocrService.extractTextFromImage( + widget.imagePaths.first, + ); + if (!firstResult.isSuccess || + !ocrService.validateOCRResult(firstResult)) { + throw Exception( + 'Gagal membaca menu. Pastikan gambar jelas dan berisi teks menu.', + ); + } + allMenuItems.addAll(firstResult.menuItems); + combinedText = firstResult.processedText; + } + } + + // Step 2: Get user data (preferences + daily check-in) + setState(() => _progress = 0.5); + + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) { + throw Exception('Pengguna tidak terautentikasi'); + } + + final userData = await firebaseService.getUserData(currentUser.uid); + final userPreferences = userData?.preferences ?? UserPreferences(); + + // Step 3: Generate recommendations โ€” semua data (profil + daily) dikirim sekaligus + setState(() => _progress = 0.7); + + final geminiService = GeminiService(); + final recommendation = await geminiService.generateRecommendations( + menuItems: allMenuItems, + userPreferences: userPreferences, + userId: currentUser.uid, + menuImageUrl: widget.previousScan?.menuImageUrl ?? (widget.imagePaths.isNotEmpty ? widget.imagePaths.first : null), + originalMenuText: combinedText, + userModel: userData, + menuImagePaths: widget.previousScan?.menuImagePaths ?? widget.imagePaths, + ); + + // Step 4: Save as Draft + setState(() => _progress = 0.9); + await firebaseService.saveRecommendation(recommendation); + + // Step 5: Navigate + setState(() => _progress = 1.0); + await Future.delayed(const Duration(milliseconds: 500)); + + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => RecommendationScreen( + recommendation: recommendation, + ocrResult: null, + ), + ), + ); + } + } catch (e) { + debugPrint('Image processing error: $e'); + if (mounted) { + _showErrorDialog(e.toString()); + } + } + } + + void _showErrorDialog(String error) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.error_outline, + color: Colors.red.shade600, + size: 24, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Gagal Memproses', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Terjadi kesalahan saat memproses gambar menu:', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Text( + error.length > 200 ? '${error.substring(0, 200)}...' : error, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: Colors.red.shade700, + ), + ), + ), + const SizedBox(height: 16), + const Text( + 'Tips:', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + _buildTipItem('Pastikan gambar menu terlihat jelas'), + _buildTipItem('Foto dari berbagai sudut bisa membantu'), + _buildTipItem('Pastikan koneksi internet aktif'), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + child: Text( + 'Kembali', + style: TextStyle( + fontFamily: 'Poppins', + color: Colors.red.shade600, + ), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + setState(() { + _isProcessing = true; + _progress = 0.0; + }); + _processImage(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8B5E3C), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + 'Coba Lagi', + style: TextStyle(fontFamily: 'Poppins', color: Colors.white), + ), + ), + ], + ), + ); + } + + Widget _buildTipItem(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.check_circle_outline, + size: 16, + color: Colors.green.shade600, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: Colors.grey, + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final msg = _cycleMessages[_cycleIndex]; + + return Scaffold( + backgroundColor: const Color(0xFFFAF6F1), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/Loading Scan.gif', + width: 220, + height: 220, + fit: BoxFit.contain, + ), + const SizedBox(height: 32), + AnimatedSwitcher( + duration: const Duration(milliseconds: 400), + child: Column( + key: ValueKey(_cycleIndex), + children: [ + Text( + msg.title, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF4A3C3C), + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + msg.subtitle, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + color: Color(0xFF8B6F6F), + ), + ), + ), + ], + ), + ), + if (widget.imagePaths.length > 1) ...[ + const SizedBox(height: 16), + Text( + '${widget.imagePaths.length} foto sedang dianalisis', + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: Color(0xFFB0A0A0), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/screens/camera/photo_picker_screen.dart b/lib/screens/camera/photo_picker_screen.dart new file mode 100644 index 0000000..93eb335 --- /dev/null +++ b/lib/screens/camera/photo_picker_screen.dart @@ -0,0 +1,1009 @@ +import 'dart:io'; +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'camera_screen.dart'; +import '../../services/firebase_service.dart'; +import '../../models/recommendation_model.dart'; +import '../recommendation/recommendation_screen.dart'; + +/// Layar pemilihan foto menu โ€” UX seperti upload gambar di AI. +/// User bisa tambah/hapus foto (maks 3) sebelum scan. +class PhotoPickerScreen extends StatefulWidget { + final List initialPaths; + const PhotoPickerScreen({super.key, this.initialPaths = const []}); + + @override + State createState() => _PhotoPickerScreenState(); +} + +class _PhotoPickerScreenState extends State { + final List _selectedPaths = []; + bool _isLoading = false; + List _history = []; + bool _isLoadingHistory = true; + + static const int _maxPhotos = 3; + static const Color _brown = Color(0xFF8B5E3C); + static const Color _darkBrown = Color(0xFF3C2415); + static const Color _bgColor = Color(0xFFFAF7F4); + + @override + void initState() { + super.initState(); + _selectedPaths.addAll(widget.initialPaths); + _loadHistory(); + } + + Future _loadHistory() async { + try { + final user = FirebaseService().currentUser; + if (user != null) { + final allHistory = await FirebaseService().getUserRecommendations(user.uid, limit: 30); + final Map uniqueMenus = {}; + final now = DateTime.now(); + + for (var rec in allHistory) { + // Otomatis hapus draf yang lebih dari 24 jam dan belum dipilih + if (now.difference(rec.createdAt).inHours > 24) { + if (rec.selectedMenuName == null) { + FirebaseService().deleteRecommendation(rec.id); + } + continue; + } + + final key = rec.originalMenuText.length > 50 + ? rec.originalMenuText.substring(0, 50) + : rec.originalMenuText; + if (!uniqueMenus.containsKey(key)) { + uniqueMenus[key] = rec; + } + } + if (mounted) { + setState(() { + _history = uniqueMenus.values.take(3).toList(); + _isLoadingHistory = false; + }); + } + } else { + if (mounted) setState(() => _isLoadingHistory = false); + } + } catch (e) { + if (mounted) setState(() => _isLoadingHistory = false); + } + } + + // โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Future _pickFromGallery() async { + if (_selectedPaths.length >= _maxPhotos) { + _showMaxReached(); + return; + } + try { + final remaining = _maxPhotos - _selectedPaths.length; + final images = await ImagePicker().pickMultiImage( + imageQuality: 80, + maxWidth: 1920, + maxHeight: 1920, + limit: remaining, + ); + if (images.isEmpty) return; + setState( + () => _selectedPaths.addAll(images.take(remaining).map((f) => f.path)), + ); + } catch (_) { + _showError('Gagal membuka galeri'); + } + } + + Future _pickFromCamera() async { + if (_selectedPaths.length >= _maxPhotos) { + _showMaxReached(); + return; + } + try { + final image = await ImagePicker().pickImage( + source: ImageSource.camera, + imageQuality: 80, + maxWidth: 1920, + maxHeight: 1920, + ); + if (image == null) return; + if (await File(image.path).exists()) + setState(() => _selectedPaths.add(image.path)); + } catch (_) { + _showError('Gagal membuka kamera'); + } + } + + void _removePhoto(int index) => + setState(() => _selectedPaths.removeAt(index)); + + void _showMaxReached() { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Maksimal 3 foto dapat dipilih', + style: TextStyle(fontFamily: 'Poppins'), + ), + backgroundColor: _brown, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 2), + ), + ); + } + + void _showError(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg, style: const TextStyle(fontFamily: 'Poppins')), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + ), + ); + } + + Future _deleteHistory(RecommendationModel rec) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Hapus Draf?', style: TextStyle(fontFamily: 'Poppins', fontWeight: FontWeight.bold, color: _darkBrown)), + content: const Text('Draf menu ini akan dihapus dari riwayat scan Anda.'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Batal', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade600), + child: const Text('Hapus', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm == true) { + setState(() { + _history.remove(rec); + }); + // Hanya hapus dari database jika belum pernah dipilih ke riwayat utama + if (rec.selectedMenuName == null) { + FirebaseService().deleteRecommendation(rec.id); + } + } + } + + Future _proceedToScan() async { + if (_selectedPaths.isEmpty) return; + setState(() => _isLoading = true); + await Future.delayed(const Duration(milliseconds: 80)); + if (!mounted) return; + await Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (_, a, __) => + CameraScreen(imagePaths: List.from(_selectedPaths)), + transitionsBuilder: (_, a, __, child) => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)).animate(a), + child: child, + ), + transitionDuration: const Duration(milliseconds: 350), + ), + ); + if (mounted) setState(() => _isLoading = false); + } + + Future _proceedWithHistory(RecommendationModel scan) async { + setState(() => _isLoading = true); + await Future.delayed(const Duration(milliseconds: 80)); + if (!mounted) return; + await Navigator.of(context).push( + PageRouteBuilder( + pageBuilder: (_, a, __) => + RecommendationScreen(recommendation: scan, ocrResult: null), + transitionsBuilder: (_, a, __, child) => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).chain(CurveTween(curve: Curves.easeOutCubic)).animate(a), + child: child, + ), + transitionDuration: const Duration(milliseconds: 350), + ), + ); + if (mounted) setState(() => _isLoading = false); + } + + void _showAddPhotoMenu() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFD4CFC8), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + const Text( + 'Tambah Foto Menu', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w700, + fontSize: 17, + color: _darkBrown, + ), + ), + const SizedBox(height: 4), + Text( + '${_selectedPaths.length}/$_maxPhotos foto dipilih', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: _brown.withOpacity(0.65), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: _buildSheetOption( + icon: Icons.photo_library_rounded, + label: 'Galeri', + subtitle: 'Pilih beberapa', + color: _brown, + onTap: () { + Navigator.pop(ctx); + _pickFromGallery(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildSheetOption( + icon: Icons.camera_alt_rounded, + label: 'Kamera', + subtitle: 'Ambil foto', + color: const Color(0xFF6B3A2A), + onTap: () { + Navigator.pop(ctx); + _pickFromCamera(); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + // โ”€โ”€ Build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _bgColor, + appBar: _buildAppBar(), + body: SafeArea( + child: Column( + children: [ + Expanded(child: _buildBody()), + _buildBottomBar(), + ], + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar() => AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon( + Icons.arrow_back_ios_rounded, + color: _darkBrown, + size: 20, + ), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Pilih Foto Menu', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w700, + fontSize: 18, + color: _darkBrown, + ), + ), + centerTitle: true, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(height: 1, color: const Color(0xFFF0EBE4)), + ), + ); + + Widget _buildBody() => SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoHeader(), + const SizedBox(height: 20), + _buildPhotoGrid(), + const SizedBox(height: 24), + _buildHistorySection(), + const SizedBox(height: 24), + _buildTips(), + ], + ), + ); + + Widget _buildInfoHeader() { + final count = _selectedPaths.length; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [_brown.withOpacity(0.08), _brown.withOpacity(0.03)], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _brown.withOpacity(0.15)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _brown.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.document_scanner_rounded, + color: _brown, + size: 22, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + count == 0 ? 'Belum ada foto dipilih' : '$count foto dipilih', + style: const TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w700, + fontSize: 14, + color: _darkBrown, + ), + ), + Text( + 'Maksimal $_maxPhotos foto menu ยท ketuk slot untuk menambah', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + color: _brown.withOpacity(0.7), + ), + ), + ], + ), + ), + const SizedBox(width: 10), + _buildDotProgress(), + ], + ), + ); + } + + Widget _buildDotProgress() => Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(_maxPhotos, (i) { + final filled = i < _selectedPaths.length; + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.only(left: 5), + width: filled ? 10 : 8, + height: filled ? 10 : 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: filled ? _brown : _brown.withOpacity(0.2), + border: Border.all( + color: filled ? _brown : _brown.withOpacity(0.3), + width: 1.5, + ), + ), + ); + }), + ); + + Widget _buildPhotoGrid() { + final count = _selectedPaths.length; + final showAdd = count < _maxPhotos; + final totalCells = count + (showAdd ? 1 : 0); + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 0.85, + ), + itemCount: totalCells, + itemBuilder: (ctx, i) => + i < count ? _buildPhotoThumbnail(i) : _buildAddSlot(), + ); + } + + Widget _buildPhotoThumbnail(int index) => Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.file( + File(_selectedPaths[index]), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (_, __, ___) => Container( + color: const Color(0xFFE8E0D8), + child: const Icon( + Icons.broken_image_rounded, + color: Colors.white54, + size: 32, + ), + ), + ), + ), + // Gradient overlay atas + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: 36, + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: Radius.circular(14)), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black.withOpacity(0.35), Colors.transparent], + ), + ), + ), + ), + // Nomor badge + Positioned( + top: 6, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: _brown, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${index + 1}', + style: const TextStyle( + fontFamily: 'Poppins', + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + // Tombol hapus + Positioned( + top: 4, + right: 4, + child: GestureDetector( + onTap: () => _removePhoto(index), + child: Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: Colors.red.shade600, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.close_rounded, + color: Colors.white, + size: 14, + ), + ), + ), + ), + ], + ); + + Widget _buildHistorySection() { + if (_isLoadingHistory) { + return const Center(child: CircularProgressIndicator(color: _brown)); + } + if (_history.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Gunakan Menu Sebelumnya', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w700, + fontSize: 16, + color: _darkBrown, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 140, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _history.length, + separatorBuilder: (context, index) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final rec = _history[index]; + // Buat judul simulasi dari menu rekomendasi pertama + final title = rec.recommendations.isNotEmpty + ? rec.recommendations.first.menuName + : 'Menu Cafe'; + + return Stack( + children: [ + GestureDetector( + onTap: () => _proceedWithHistory(rec), + child: Container( + width: 130, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _brown.withOpacity(0.15)), + boxShadow: [ + BoxShadow( + color: _brown.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: _buildHistoryImage(rec), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w600, + fontSize: 12, + color: _darkBrown, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${rec.recommendations.length} menu', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 10, + color: _brown.withOpacity(0.8), + ), + ), + ], + ), + ), + ], + ), + ), + ), + Positioned( + top: 6, + right: 6, + child: GestureDetector( + onTap: () => _deleteHistory(rec), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red.shade600, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.close_rounded, + size: 14, + color: Colors.white, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ); + } + + Widget _buildHistoryImage(RecommendationModel rec) { + if (rec.menuImageUrl != null && rec.menuImageUrl!.startsWith('http')) { + return Image.network( + rec.menuImageUrl!, + height: 70, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _fallbackImage(), + ); + } else if (rec.menuImagePaths != null && rec.menuImagePaths!.isNotEmpty) { + final file = File(rec.menuImagePaths!.first); + if (file.existsSync()) { + return Image.file( + file, + height: 70, + width: double.infinity, + fit: BoxFit.cover, + ); + } + } + return _fallbackImage(); + } + + Widget _fallbackImage() => Container( + height: 70, + width: double.infinity, + color: _brown.withOpacity(0.1), + child: Icon(Icons.restaurant_menu_rounded, color: _brown.withOpacity(0.5)), + ); + + /// Slot kosong bertitik dashed โ€” mengindikasikan "bisa tambah lebih" tanpa tombol eksplisit + Widget _buildAddSlot() => GestureDetector( + onTap: _showAddPhotoMenu, + child: CustomPaint( + painter: _DashedBorderPainter(color: _brown.withOpacity(0.3), radius: 14), + child: Container( + decoration: BoxDecoration( + color: _brown.withOpacity(0.025), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Dua ikon mini berdampingan sebagai hint sumber + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _miniIcon(Icons.photo_library_rounded), + Container( + margin: const EdgeInsets.symmetric(horizontal: 6), + width: 1, + height: 16, + color: _brown.withOpacity(0.2), + ), + _miniIcon(Icons.camera_alt_rounded), + ], + ), + const SizedBox(height: 8), + // Lingkaran "+" kecil + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: _brown.withOpacity(0.45), width: 1.5), + ), + child: Icon( + Icons.add_rounded, + size: 13, + color: _brown.withOpacity(0.6), + ), + ), + const SizedBox(height: 6), + Text( + '${_maxPhotos - _selectedPaths.length} slot tersisa', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 10, + fontWeight: FontWeight.w500, + color: _brown.withOpacity(0.5), + height: 1.3, + ), + ), + ], + ), + ), + ), + ); + + Widget _miniIcon(IconData icon) => Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: _brown.withOpacity(0.08), + borderRadius: BorderRadius.circular(7), + ), + child: Icon(icon, size: 14, color: _brown.withOpacity(0.6)), + ); + + Widget _buildTips() => Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE8DFD4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon( + Icons.lightbulb_outline_rounded, + size: 15, + color: Color(0xFFE5A100), + ), + SizedBox(width: 6), + Text( + 'Tips Scan Menu', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w700, + fontSize: 12, + color: _darkBrown, + ), + ), + ], + ), + const SizedBox(height: 8), + ...[ + 'Foto menu dengan pencahayaan yang baik', + 'Jika menu terdiri dari beberapa halaman, tambah semua', + 'Pastikan tulisan harga terbaca jelas', + ].map( + (tip) => Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'โ€ข ', + style: TextStyle( + color: _brown, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + Expanded( + child: Text( + tip, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + color: Color(0xFF6B5B4E), + height: 1.4, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + + Widget _buildBottomBar() => Container( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + decoration: BoxDecoration( + color: Colors.white, + border: const Border(top: BorderSide(color: Color(0xFFF0EBE4))), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, -4), + ), + ], + ), + child: SafeArea( + top: false, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: _selectedPaths.isNotEmpty + ? _buildScanButton() + : _buildEmptyPrompt(), + ), + ), + ); + + Widget _buildScanButton() => SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : _proceedToScan, + style: ElevatedButton.styleFrom( + backgroundColor: _brown, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 4, + shadowColor: _brown.withOpacity(0.4), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.document_scanner_rounded, size: 20), + const SizedBox(width: 10), + Text( + 'Scan ${_selectedPaths.length} Foto Menu', + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ); + + Widget _buildEmptyPrompt() => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.touch_app_rounded, size: 16, color: _brown.withOpacity(0.5)), + const SizedBox(width: 8), + Flexible( + child: Text( + 'Ketuk slot bergaris putus untuk menambah foto', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: _brown.withOpacity(0.6), + ), + ), + ), + ], + ); + + Widget _buildSheetOption({ + required IconData icon, + required String label, + required String subtitle, + required Color color, + required VoidCallback onTap, + }) => GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [color, color.withOpacity(0.8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white, size: 28), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + fontFamily: 'Poppins', + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + Text( + subtitle, + style: TextStyle( + fontFamily: 'Poppins', + color: Colors.white.withOpacity(0.8), + fontSize: 11, + ), + ), + ], + ), + ), + ); +} + +/// CustomPainter untuk border dashed pada slot tambah foto +class _DashedBorderPainter extends CustomPainter { + final Color color; + final double radius; + final double dashWidth; + final double dashGap; + + const _DashedBorderPainter({ + required this.color, + this.radius = 12, + this.dashWidth = 6, + this.dashGap = 4, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + final path = Path() + ..addRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(1, 1, size.width - 2, size.height - 2), + Radius.circular(radius), + ), + ); + + final metrics = path.computeMetrics(); + for (final metric in metrics) { + double distance = 0; + while (distance < metric.length) { + final len = math.min(dashWidth, metric.length - distance); + canvas.drawPath(metric.extractPath(distance, distance + len), paint); + distance += dashWidth + dashGap; + } + } + } + + @override + bool shouldRepaint(_DashedBorderPainter old) => + old.color != color || old.radius != radius; +} diff --git a/lib/screens/chatbot/amor_chatbot_screen.dart b/lib/screens/chatbot/amor_chatbot_screen.dart new file mode 100644 index 0000000..605e821 --- /dev/null +++ b/lib/screens/chatbot/amor_chatbot_screen.dart @@ -0,0 +1,707 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_theme.dart'; +import '../../providers/amor_chat_provider.dart'; +import '../../services/gemini_service.dart'; + +// โ”€โ”€ Screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class AmorChatbotScreen extends ConsumerStatefulWidget { + const AmorChatbotScreen({super.key}); + + @override + ConsumerState createState() => _AmorChatbotScreenState(); +} + +class _AmorChatbotScreenState extends ConsumerState { + final TextEditingController _chatController = TextEditingController(); + final ScrollController _scrollCtrl = ScrollController(); + + // โ”€โ”€ Full Amor Coffee Menu โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + static const String _amorMenu = ''' +MENU AMOR COFFEE (harga dalam Rupiah): + +SIGNATURE +- Teh Sereh Lemon : Rp 10.000 +- StrawCano : Rp 13.000 +- Es Kopi Susu Amor : Rp 13.000 +- Avocalypse : Rp 15.000 + +MANUAL BREW +- V60/Kopi Filter : Rp 15.000 +- Kopi Tubruk : Rp 7.000 + +COFFEE +- Americano : Rp 10.000 +- Aren Latte : Rp 13.000 +- Caramel Latte : Rp 14.000 +- Hazelnut Latte : Rp 14.000 +- Vanilla Latte : Rp 14.000 +- Cappucino (Hot) : Rp 14.000 +- ButterScotch Latte : Rp 18.000 + +SQUASH +- Strawberry : Rp 12.000 +- Melon : Rp 12.000 +- Grape : Rp 12.000 +- Lemon : Rp 13.000 +- Lychee : Rp 12.000 + +TEA +- Original Tea : Rp 7.000 +- Strawberry Tea : Rp 10.000 +- Lychee Tea : Rp 10.000 +- Lemon Tea : Rp 10.000 +- Thai Tea : Rp 12.000 + +MILK BASED +- Avocado : Rp 12.000 +- Taro : Rp 12.000 +- Redvelvet : Rp 12.000 +- Pink Lava : Rp 12.000 +- Chocolate : Rp 12.000 +- Choco Caramel : Rp 13.000 +- Choco Hazelnut : Rp 13.000 +- Matcha : Rp 12.000 +- Matcha Coconut : Rp 14.000 + +OTHERS (Minuman) +- Extra Shot Espresso : Rp 4.000 +- Mineral : Rp 5.000 + +FOOD +- Nasilor (Nasi Sosis Telor) : Rp 10.000 +- Chicken Katsu Mentai : Rp 12.000 +- Chicken Katsu Blackpapper : Rp 12.000 +- Chicken Katsu Spicy : Rp 12.000 +- Indomie Kuah Amor (Indomie Kuah Topping Telur, Bakso, Sosis) : Rp 12.000 +- Indomie Goreng Amor (Indomie Goreng Topping Telur, Mozzarella, Bakso) : Rp 15.000 + +SNACK +- Donat Amor (choco/matcha/tiramisu) : Rp 10.000 +- French Fries : Rp 10.000 +- Ropang manis (choco/matcha/tiramisu): Rp 10.000 +- Ropang Telur : Rp 11.000 +- Ropang Beef : Rp 13.000 +- Pisang Choco : Rp 12.000 +- Tempe Mendoan : Rp 12.000 +- Gyoza : Rp 13.000 +- Dimsum Ayam : Rp 13.000 +- Dimsum Mentai : Rp 15.000 +- Mix Sampler : Rp 15.000 +'''; + + final List _suggestions = [ + 'Menu apa yang paling populer? ๐Ÿ”ฅ', + 'Ada menu di bawah Rp 12.000? ๐Ÿ’ฐ', + 'Rekomendasiin minuman dingin dong ๐ŸงŠ', + 'Paket makan + minum hemat? ๐ŸŽ', + 'Apa saja menu makanan berat? ๐Ÿฝ๏ธ', + 'Perbedaan Matcha & Matcha Coconut? ๐Ÿต', + ]; + + @override + void initState() { + super.initState(); + // Muat riwayat dari Firestore saat pertama kali dibuka + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(amorChatProvider.notifier).loadHistory(); + }); + } + + @override + void dispose() { + _chatController.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + // โ”€โ”€ Send โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future _sendMessage() async { + final message = _chatController.text.trim(); + final notifier = ref.read(amorChatProvider.notifier); + final isLoading = ref.read(amorChatProvider).isLoading; + if (message.isEmpty || isLoading) return; + + _chatController.clear(); + notifier.addUserMessage(message); + notifier.setLoading(true); + _scrollToBottom(); + + try { + final reply = await GeminiService().chatWithMenu( + message: message, + menuContext: _amorMenu, + history: notifier.aiHistory, + ); + await notifier.addAIMessage(reply); + } catch (e) { + await notifier.addAIMessage('Maaf, terjadi kesalahan. Coba lagi ya! ๐Ÿ˜…'); + } finally { + notifier.setLoading(false); + _scrollToBottom(); + } + } + + void _sendSuggestion(String s) { + _chatController.text = s.replaceAll(RegExp(r'\s+\S+$'), '').trim(); + _sendMessage(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) { + _scrollCtrl.animateTo( + _scrollCtrl.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + // โ”€โ”€ Build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + @override + Widget build(BuildContext context) { + final chatState = ref.watch(amorChatProvider); + final messages = chatState.messages; + final isLoading = chatState.isLoading; + + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: _buildAppBar(messages.isNotEmpty), + body: Column( + children: [ + Expanded( + child: messages.isEmpty && !isLoading + ? _buildPlaceholder() + : _buildMessageList(messages, isLoading), + ), + _buildChatInput(isLoading), + ], + ), + ); + } + + // โ”€โ”€ AppBar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + PreferredSizeWidget _buildAppBar(bool hasHistory) { + return AppBar( + backgroundColor: Colors.white, + foregroundColor: AppTheme.primaryBrown, + elevation: 0, + surfaceTintColor: Colors.white, + title: Row( + children: [ + CircleAvatar( + radius: 16, + backgroundColor: AppTheme.primaryBrown, + child: const Icon(Icons.smart_toy, size: 16, color: Colors.white), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Amori', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Asisten Menu Amor Coffee', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.grey500, + fontSize: 11, + ), + ), + ], + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.menu_book_outlined), + tooltip: 'Lihat daftar menu', + color: AppTheme.primaryBrown, + onPressed: _showMenuSheet, + ), + if (hasHistory) + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Hapus riwayat chat', + color: AppTheme.primaryBrown, + onPressed: _confirmClear, + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Divider(height: 1, color: AppTheme.grey200), + ), + ); + } + + Future _confirmClear() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Hapus Riwayat Chat?'), + content: const Text( + 'Semua riwayat percakapan dengan Amori akan dihapus permanen.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Batal')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Hapus', + style: TextStyle(color: Colors.red))), + ], + ), + ); + if (ok == true) { + await ref.read(amorChatProvider.notifier).clearHistory(); + } + } + + // โ”€โ”€ Message List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Widget _buildMessageList(List messages, bool isLoading) { + return ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.all(AppConstants.defaultPadding), + itemCount: messages.length + (isLoading ? 1 : 0), + itemBuilder: (context, i) { + if (i == messages.length) return _buildTypingIndicator(); + return _buildChatMessage(messages[i]); + }, + ); + } + + // โ”€โ”€ Placeholder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Widget _buildPlaceholder() { + return SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 40), + Icon(Icons.chat_bubble_outline, size: 64, color: AppTheme.grey400), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Halo! Senang sekali bisa membantu Anda mengetahui menu Amor Coffee.', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.grey800, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + 'Ajukan pertanyaan tentang menu, harga, atau minta rekomendasi sesuai selera kamu', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: AppTheme.grey500), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 28), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: _suggestions.map((s) { + return GestureDetector( + onTap: () => _sendSuggestion(s), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: AppTheme.primaryBrown.withOpacity(0.3)), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + s, + style: TextStyle( + fontSize: 12, + fontFamily: 'Poppins', + color: AppTheme.primaryBrown, + ), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 40), + ], + ), + ); + } + + // โ”€โ”€ Chat Bubble โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Widget _buildChatMessage(AmorChatEntry message) { + final isUser = message.isUser; + return Container( + margin: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) ...[ + CircleAvatar( + radius: 16, + backgroundColor: AppTheme.primaryBrown, + child: + const Icon(Icons.smart_toy, size: 16, color: Colors.white), + ), + const SizedBox(width: 8), + ], + Flexible( + child: Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + isUser ? AppTheme.primaryBrown : Colors.white, + borderRadius: BorderRadius.circular(16).copyWith( + bottomLeft: isUser + ? const Radius.circular(16) + : const Radius.circular(4), + bottomRight: isUser + ? const Radius.circular(4) + : const Radius.circular(16), + ), + border: isUser + ? null + : Border.all(color: AppTheme.grey300, width: 1), + boxShadow: isUser + ? null + : [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + message.text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isUser ? Colors.white : Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + const SizedBox(height: 2), + Text( + _formatTime(message.time), + style: TextStyle( + fontSize: 10, + color: AppTheme.grey500, + fontFamily: 'Poppins', + ), + ), + ], + ), + ), + if (isUser) ...[ + const SizedBox(width: 8), + CircleAvatar( + radius: 16, + backgroundColor: AppTheme.grey300, + child: + const Icon(Icons.person, size: 16, color: Colors.white), + ), + ], + ], + ), + ).animate().fade().slideY(begin: 0.2, end: 0, curve: Curves.easeOut); + } + + String _formatTime(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + // โ”€โ”€ Typing Indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Widget _buildTypingIndicator() { + return Container( + margin: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 16, + backgroundColor: AppTheme.primaryBrown, + child: + const Icon(Icons.smart_toy, size: 16, color: Colors.white), + ), + const SizedBox(width: 8), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + bottomLeft: Radius.circular(4), + ), + border: Border.all(color: AppTheme.grey300, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: 7, + height: 7, + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.5), + shape: BoxShape.circle, + ), + ) + .animate(onPlay: (c) => c.repeat()) + .scaleXY( + delay: Duration(milliseconds: i * 150), + duration: 500.ms, + begin: 0.6, + end: 1.0) + .then() + .scaleXY(duration: 500.ms, begin: 1.0, end: 0.6); + }), + ), + ), + ], + ), + ); + } + + // โ”€โ”€ Input Bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Widget _buildChatInput(bool isLoading) { + return Container( + color: Colors.white, + child: Container( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _chatController, + onSubmitted: (_) => _sendMessage(), + style: + const TextStyle(color: Colors.black, fontSize: 16), + maxLines: null, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Tanya tentang menu Amor Coffee...', + hintStyle: TextStyle( + color: AppTheme.grey500, + fontSize: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: AppTheme.grey50, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + const SizedBox(width: 8), + Container( + decoration: const BoxDecoration( + color: AppTheme.primaryBrown, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: isLoading ? null : _sendMessage, + icon: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white), + ), + ) + : const Icon(Icons.send, color: Colors.white), + ), + ), + ], + ), + ), + ); + } + + // โ”€โ”€ Menu Sheet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + void _showMenuSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.85, + maxChildSize: 0.95, + minChildSize: 0.4, + builder: (ctx, ctrl) => Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.grey300, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 8), + child: Row( + children: [ + Icon(Icons.menu_book_outlined, + color: AppTheme.primaryBrown), + const SizedBox(width: 8), + Text( + 'Daftar Menu Amor Coffee', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + ), + ), + ], + ), + ), + Divider(height: 1, color: AppTheme.grey200), + Expanded( + child: SingleChildScrollView( + controller: ctrl, + padding: + const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _menuCat('โ˜• Signature', [('Teh Sereh Lemon', 10000), ('StrawCano', 13000), ('Es Kopi Susu Amor', 13000), ('Avocalypse', 15000)]), + _menuCat('๐Ÿซ— Manual Brew', [('V60/Kopi Filter', 15000), ('Kopi Tubruk', 7000)]), + _menuCat('โ˜• Coffee', [('Americano', 10000), ('Aren Latte', 13000), ('Caramel Latte', 14000), ('Hazelnut Latte', 14000), ('Vanilla Latte', 14000), ('Cappucino (Hot)', 14000), ('ButterScotch Latte', 18000)]), + _menuCat('๐Ÿฅค Squash', [('Strawberry', 12000), ('Melon', 12000), ('Grape', 12000), ('Lemon', 13000), ('Lychee', 12000)]), + _menuCat('๐Ÿต Tea', [('Original Tea', 7000), ('Strawberry Tea', 10000), ('Lychee Tea', 10000), ('Lemon Tea', 10000), ('Thai Tea', 12000)]), + _menuCat('๐Ÿฅ› Milk Based', [('Avocado', 12000), ('Taro', 12000), ('Redvelvet', 12000), ('Pink Lava', 12000), ('Chocolate', 12000), ('Choco Caramel', 13000), ('Choco Hazelnut', 13000), ('Matcha', 12000), ('Matcha Coconut', 14000)]), + _menuCat('๐Ÿ’ง Others', [('Extra Shot Espresso', 4000), ('Mineral', 5000)]), + _menuCat('๐Ÿฝ๏ธ Food', [('Nasilor (Nasi Sosis Telor)', 10000), ('Chicken Katsu Mentai', 12000), ('Chicken Katsu Blackpapper', 12000), ('Chicken Katsu Spicy', 12000), ('Indomie Kuah Amor', 12000), ('Indomie Goreng Amor', 15000)]), + _menuCat('๐ŸŸ Snack', [('Donat Amor', 10000), ('French Fries', 10000), ('Ropang manis', 10000), ('Ropang Telur', 11000), ('Ropang Beef', 13000), ('Pisang Choco', 12000), ('Tempe Mendoan', 12000), ('Gyoza', 13000), ('Dimsum Ayam', 13000), ('Dimsum Mentai', 15000), ('Mix Sampler', 15000)]), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _menuCat(String title, List<(String, int)> items) { + String fmt(int p) => p.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (m) => '${m[1]}.'); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 14), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.08), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + ), + ), + ), + const SizedBox(height: 6), + ...items.map((item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(item.$1, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: const Color(0xFF2D1B0A))), + ), + Text( + 'Rp ${fmt(item.$2)}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: AppTheme.primaryBrown, + ), + ), + ], + ), + )), + Divider(height: 1, color: AppTheme.grey200), + ], + ); + } +} diff --git a/lib/screens/history/history_screen.dart b/lib/screens/history/history_screen.dart new file mode 100644 index 0000000..007065c --- /dev/null +++ b/lib/screens/history/history_screen.dart @@ -0,0 +1,599 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/constants/app_constants.dart'; +import '../../models/recommendation_model.dart'; +import '../../services/firebase_service.dart'; +import '../recommendation/recommendation_screen.dart'; +import '../../services/ocr_service.dart'; +import '../../services/dialog_service.dart'; + +class HistoryScreen extends ConsumerStatefulWidget { + final int initialIndex; + + const HistoryScreen({super.key, this.initialIndex = 0}); + + @override + ConsumerState createState() => _HistoryScreenState(); +} + +class _HistoryScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + List _allRecommendations = []; + List _favoriteRecommendations = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: 2, + vsync: this, + initialIndex: widget.initialIndex, + ); + _loadRecommendations(); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _loadRecommendations() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) { + // For now, show empty state instead of error for unauthenticated users + if (mounted) { + setState(() { + _allRecommendations = []; + _favoriteRecommendations = []; + _isLoading = false; + }); + } + return; + } + + // Try to get recommendations with simpler query to avoid index issues + final recommendations = await firebaseService.getUserRecommendations( + currentUser.uid, + limit: 20, // Reduce limit to avoid performance issues + ); + + if (mounted) { + setState(() { + // Hanya tampilkan riwayat yang sudah dipilih menunya + _allRecommendations = recommendations.where((r) => r.selectedMenuName != null).toList(); + _favoriteRecommendations = _allRecommendations + .where((rec) => rec.isFavorite) + .toList(); + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + // Show more user-friendly error message + _error = + 'Tidak dapat memuat riwayat. Pastikan Anda sudah login dan memiliki koneksi internet.'; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: const Text( + 'Riwayat Rekomendasi', + style: TextStyle( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: AppTheme.warmWhite, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.primaryBrown), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadRecommendations, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: AppTheme.primaryBrown), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'clear_all', + child: Row( + children: [ + Icon(Icons.clear_all, color: Colors.red), + SizedBox(width: 8), + Text('Hapus Semua'), + ], + ), + ), + const PopupMenuItem( + value: 'export', + child: Row( + children: [ + Icon(Icons.download), + SizedBox(width: 8), + Text('Export Data'), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'clear_all') { + _showClearAllDialog(); + } else if (value == 'export') { + _exportData(); + } + }, + ), + ], + bottom: TabBar( + controller: _tabController, + labelColor: AppTheme.primaryBrown, + unselectedLabelColor: AppTheme.grey500, + indicatorColor: AppTheme.primaryBrown, + tabs: [ + Tab(text: 'Semua (${_allRecommendations.length})'), + Tab(text: 'Favorit (${_favoriteRecommendations.length})'), + ], + ), + ), + body: _isLoading + ? _buildLoadingState() + : _error != null + ? _buildErrorState() + : TabBarView( + controller: _tabController, + children: [ + _buildRecommendationsList(_allRecommendations), + _buildRecommendationsList(_favoriteRecommendations), + ], + ), + ); + } + + Widget _buildLoadingState() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Memuat riwayat...'), + ], + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: AppTheme.error), + const SizedBox(height: 16), + Text( + 'Gagal memuat riwayat', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + _error ?? 'Terjadi kesalahan', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.grey600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _loadRecommendations, + child: const Text('Coba Lagi'), + ), + ], + ), + ); + } + + Widget _buildRecommendationsList(List recommendations) { + if (recommendations.isEmpty) { + return _buildEmptyState(); + } + + final bottomPadding = + MediaQuery.of(context).padding.bottom + + kBottomNavigationBarHeight + + 16.0; + + return RefreshIndicator( + onRefresh: _loadRecommendations, + child: ListView.builder( + padding: EdgeInsets.fromLTRB( + AppConstants.defaultPadding, + AppConstants.defaultPadding, + AppConstants.defaultPadding, + bottomPadding, + ), + itemCount: recommendations.length, + itemBuilder: (context, index) { + return _buildRecommendationCard(recommendations[index]); + }, + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, size: 64, color: AppTheme.grey400), + const SizedBox(height: 16), + Text( + 'Belum ada riwayat', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.grey700, + ), + ), + const SizedBox(height: 8), + Text( + 'Mulai ambil foto menu untuk mendapatkan rekomendasi personal dari AI', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.grey600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () { + // Navigate to camera or home screen + Navigator.of(context).popUntil((route) => route.isFirst); + }, + icon: const Icon(Icons.camera_alt), + label: const Text('Ambil Foto Menu'), + ), + ], + ), + ), + ); + } + + Widget _buildRecommendationCard(RecommendationModel recommendation) { + final topRecommendations = recommendation.topRecommendations; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: InkWell( + onTap: () => _openRecommendation(recommendation), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recommendation.formattedDate, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + '${recommendation.recommendations.length} rekomendasi', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: AppTheme.grey600), + ), + ], + ), + ), + // Confidence Badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: _getConfidenceColor( + recommendation.confidence, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getConfidenceColor( + recommendation.confidence, + ).withOpacity(0.3), + ), + ), + child: Text( + '${(recommendation.confidence * 100).toInt()}%', + style: TextStyle( + color: _getConfidenceColor(recommendation.confidence), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + // Favorite Icon + Icon( + recommendation.isFavorite + ? Icons.favorite + : Icons.favorite_border, + color: recommendation.isFavorite + ? AppTheme.error + : AppTheme.grey400, + size: 20, + ), + ], + ), + + const SizedBox(height: 12), + + // Top Recommendations Preview + if (topRecommendations.isNotEmpty) ...[ + Text( + 'Rekomendasi Teratas:', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.grey600, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ...topRecommendations + .take(2) + .map((rec) => _buildMiniRecommendationItem(rec)) + .toList(), + if (topRecommendations.length > 2) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '+${topRecommendations.length - 2} lainnya', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + + const SizedBox(height: 12), + + // Action Buttons + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _openRecommendation(recommendation), + icon: const Icon(Icons.visibility, size: 16), + label: const Text('Lihat Detail'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () => _toggleFavorite(recommendation), + icon: Icon( + recommendation.isFavorite + ? Icons.favorite + : Icons.favorite_border, + color: recommendation.isFavorite + ? AppTheme.error + : AppTheme.grey500, + ), + ), + IconButton( + onPressed: () => _showDeleteDialog(recommendation), + icon: const Icon( + Icons.delete_outline, + color: AppTheme.error, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildMiniRecommendationItem(MenuRecommendation recommendation) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: AppTheme.primaryBrown, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${recommendation.menuName} - ${recommendation.formattedPrice}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.grey700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Rating Stars + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(recommendation.starRating, (index) { + return Icon(Icons.star, color: AppTheme.accent, size: 12); + }), + ), + ], + ), + ); + } + + Color _getConfidenceColor(double confidence) { + if (confidence >= 0.8) return AppTheme.success; + if (confidence >= 0.6) return AppTheme.warning; + return AppTheme.error; + } + + Future _openRecommendation(RecommendationModel recommendation) async { + // Create a mock OCR result for the recommendation screen + final ocrResult = OCRResult( + rawText: recommendation.originalMenuText, + processedText: recommendation.originalMenuText, + menuItems: recommendation.recommendations + .map( + (rec) => MenuItemOCR( + name: rec.menuName, + price: rec.price, + description: rec.description, + category: rec.category, + ), + ) + .toList(), + confidence: recommendation.confidence, + isSuccess: true, + ); + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => RecommendationScreen( + recommendation: recommendation, + ocrResult: ocrResult, + ), + ), + ); + } + + Future _toggleFavorite(RecommendationModel recommendation) async { + try { + final newFavoriteStatus = !recommendation.isFavorite; + await FirebaseService().toggleRecommendationFavorite( + recommendation.id, + newFavoriteStatus, + ); + + setState(() { + recommendation.copyWith(isFavorite: newFavoriteStatus); + _favoriteRecommendations = _allRecommendations + .where((rec) => rec.isFavorite) + .toList(); + }); + + DialogService().showSuccess( + newFavoriteStatus ? 'Ditambahkan ke favorit' : 'Dihapus dari favorit', + ); + } catch (e) { + DialogService().showError('Gagal mengupdate favorit: $e'); + } + } + + Future _showDeleteDialog(RecommendationModel recommendation) async { + final confirm = await DialogService().showConfirmDialog( + title: 'Hapus Rekomendasi', + message: 'Apakah Anda yakin ingin menghapus rekomendasi ini?', + isDanger: true, + context: context, + ); + + if (confirm) { + await _deleteRecommendation(recommendation); + } + } + + Future _deleteRecommendation(RecommendationModel recommendation) async { + try { + await FirebaseService().deleteRecommendation(recommendation.id); + + setState(() { + _allRecommendations.removeWhere((rec) => rec.id == recommendation.id); + _favoriteRecommendations.removeWhere( + (rec) => rec.id == recommendation.id, + ); + }); + + DialogService().showSuccess('Rekomendasi berhasil dihapus'); + } catch (e) { + DialogService().showError('Gagal menghapus rekomendasi: $e'); + } + } + + Future _showClearAllDialog() async { + final confirm = await DialogService().showConfirmDialog( + title: 'Hapus Semua Riwayat', + message: + 'Apakah Anda yakin ingin menghapus semua riwayat rekomendasi? Tindakan ini tidak dapat dibatalkan.', + isDanger: true, + context: context, + ); + + if (confirm) { + await _clearAllRecommendations(); + } + } + + Future _clearAllRecommendations() async { + try { + // Delete all recommendations + for (final recommendation in _allRecommendations) { + await FirebaseService().deleteRecommendation(recommendation.id); + } + + setState(() { + _allRecommendations.clear(); + _favoriteRecommendations.clear(); + }); + + DialogService().showSuccess('Semua riwayat berhasil dihapus'); + } catch (e) { + DialogService().showError('Gagal menghapus riwayat: $e'); + } + } + + void _exportData() { + DialogService().showInfo('Fitur export akan segera hadir'); + } +} diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart new file mode 100644 index 0000000..17ef9c5 --- /dev/null +++ b/lib/screens/home/home_screen.dart @@ -0,0 +1,1395 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:animated_bottom_navigation_bar/animated_bottom_navigation_bar.dart'; +import '../../core/constants/app_constants.dart'; +import '../../models/recommendation_model.dart'; +import '../../models/user_model.dart'; +import '../../services/firebase_service.dart'; +import '../../services/daily_checkin_service.dart'; +import '../camera/photo_picker_screen.dart'; +import '../history/history_screen.dart'; +import '../profile/profile_screen.dart'; +import '../onboarding/daily_checkin_screen.dart'; +import '../recommendation/recommendation_screen.dart'; +import '../chatbot/amor_chatbot_screen.dart'; + +class HomeScreen extends ConsumerStatefulWidget { + const HomeScreen({super.key}); + + @override + ConsumerState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + int _currentIndex = 0; + final PageController _pageController = PageController(); + final GlobalKey<_HomeTabState> _homeTabKey = GlobalKey<_HomeTabState>(); + late AnimationController _fabAnimController; + + @override + void initState() { + super.initState(); + _fabAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + )..forward(); + + // Show daily check-in when app first opens (non-mandatory, user can skip) + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkDailyCheckin(isMandatory: false); + }); + } + + @override + void dispose() { + _pageController.dispose(); + _fabAnimController.dispose(); + super.dispose(); + } + + void _onTabTapped(int index) { + setState(() => _currentIndex = index); + _pageController.animateToPage( + index, + duration: AppConstants.shortAnimation, + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBody: true, + body: PageView( + controller: _pageController, + onPageChanged: (index) => setState(() => _currentIndex = index), + children: [ + _HomeTab(key: _homeTabKey), + const _HistoryTab(), + const _FavoritesTab(), + const _ProfileTab(), + ], + ), + floatingActionButton: ScaleTransition( + scale: CurvedAnimation( + parent: _fabAnimController, + curve: Curves.elasticOut, + ), + child: Container( + width: 62, + height: 62, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFD4A574), Color(0xFF8B5E3C), Color(0xFF6B3A2A)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF8B5E3C).withOpacity(0.45), + blurRadius: 16, + spreadRadius: 2, + offset: const Offset(0, 6), + ), + BoxShadow( + color: const Color(0xFFD4A574).withOpacity(0.3), + blurRadius: 24, + offset: const Offset(0, 2), + ), + ], + ), + child: FloatingActionButton( + onPressed: () => _handleScanMenu(context), + backgroundColor: Colors.transparent, + elevation: 0, + shape: const CircleBorder(), + child: const Icon( + Icons.document_scanner_rounded, + color: Colors.white, + size: 26, + ), + ), + ), + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + bottomNavigationBar: AnimatedBottomNavigationBar( + icons: const [ + Icons.home_rounded, + Icons.history_rounded, + Icons.favorite_rounded, + Icons.person_rounded, + ], + activeIndex: _currentIndex, + gapLocation: GapLocation.center, + notchSmoothness: NotchSmoothness.softEdge, + notchMargin: 8, + leftCornerRadius: 24, + rightCornerRadius: 24, + activeColor: const Color(0xFF6B3A2A), + inactiveColor: const Color(0xFFB0A090), + backgroundColor: Colors.white, + splashColor: const Color(0xFF6B3A2A).withOpacity(0.08), + splashSpeedInMilliseconds: 300, + elevation: 16, + shadow: Shadow( + color: Colors.black.withOpacity(0.08), + offset: const Offset(0, -4), + blurRadius: 20, + ), + onTap: _onTabTapped, + ), + ); + } + + Future _handleScanMenu(BuildContext context) async { + final result = await _checkDailyCheckin(isMandatory: true); + if (result && mounted) _showImageSourceOptions(context); + } + + Future _checkDailyCheckin({bool isMandatory = false}) async { + try { + final user = FirebaseService().currentUser; + if (user == null) return false; + final shouldShow = await DailyCheckinService().shouldShowCheckin( + user.uid, + ); + + // Already done today โ€” skip the screen regardless of context + if (!shouldShow) return true; + + if (mounted) { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DailyCheckinScreen(isMandatory: isMandatory), + ), + ); + + if (result == true) { + // Refresh recommendations list on the home tab + _homeTabKey.currentState?._loadLatestRecommendations(); + return true; + } + return false; + } + return false; + } catch (e) { + debugPrint('Error checking daily check-in: $e'); + return false; + } + } + + void _showImageSourceOptions(BuildContext context) { + _openPhotoPicker(context); + } + + void _openPhotoPicker(BuildContext context) async { + final nav = Navigator.of(context); + await nav.push( + MaterialPageRoute(builder: (_) => const PhotoPickerScreen()), + ); + _homeTabKey.currentState?._loadLatestRecommendations(); + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// HOME TAB +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _HomeTab extends ConsumerStatefulWidget { + const _HomeTab({super.key}); + + @override + ConsumerState<_HomeTab> createState() => _HomeTabState(); +} + +class _HomeTabState extends ConsumerState<_HomeTab> { + List _latestRecommendations = []; + bool _isLoadingRecommendations = true; + UserModel? _userData; + + @override + void initState() { + super.initState(); + _loadLatestRecommendations(); + } + + Future _loadLatestRecommendations() async { + try { + final user = FirebaseService().currentUser; + if (user == null) { + if (mounted) setState(() => _isLoadingRecommendations = false); + return; + } + // Fetch a larger batch so after the 3-day filter we still have up to 10 + final all = await FirebaseService().getUserRecommendations( + user.uid, + limit: 50, + ); + final cutoff = DateTime.now().subtract(const Duration(days: 3)); + final recent = all + .where((r) => r.createdAt.isAfter(cutoff) && r.selectedMenuName != null) + .take(10) + .toList(); + // Fetch user data for nickname + final userData = await FirebaseService().getUserData(user.uid); + + if (mounted) { + setState(() { + _latestRecommendations = recent; + _isLoadingRecommendations = false; + _userData = userData; + }); + } + } catch (e) { + debugPrint('Error loading recommendations: $e'); + if (mounted) setState(() => _isLoadingRecommendations = false); + } + } + + Future _clearLatestRecommendations(BuildContext context) async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Hapus Riwayat Scan?', + style: TextStyle(fontFamily: 'Poppins', fontWeight: FontWeight.bold), + ), + content: const Text( + 'Semua riwayat scan terbaru akan dihapus secara permanen. Lanjutkan?', + style: TextStyle(fontFamily: 'Poppins', fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text( + 'Batal', + style: TextStyle(fontFamily: 'Poppins', color: Colors.grey), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text( + 'Hapus', + style: TextStyle(fontFamily: 'Poppins', color: Colors.white), + ), + ), + ], + ), + ); + + if (confirm != true) return; + + try { + for (final rec in _latestRecommendations) { + await FirebaseService().deleteRecommendation(rec.id); + } + if (mounted) { + setState(() => _latestRecommendations.clear()); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Riwayat scan berhasil dihapus', + style: TextStyle(fontFamily: 'Poppins'), + ), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Gagal menghapus: $e', + style: const TextStyle(fontFamily: 'Poppins'), + ), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F4EF), + body: RefreshIndicator( + onRefresh: _loadLatestRecommendations, + color: const Color(0xFF6B3A2A), + backgroundColor: Colors.white, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Gradient Header + SliverToBoxAdapter(child: _buildGradientHeader(context)), + // Stats Row + SliverToBoxAdapter(child: _buildQuickStats()), + // Section Title + SliverToBoxAdapter(child: _buildSectionTitle(context)), + // Content + _isLoadingRecommendations + ? SliverToBoxAdapter(child: _buildShimmerLoading()) + : _latestRecommendations.isEmpty + ? SliverToBoxAdapter(child: _buildEmptyState()) + : _buildRecommendationsSliverList(), + // Bottom padding for FAB + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ), + ), + ); + } + + Widget _buildGradientHeader(BuildContext context) { + final user = FirebaseService().currentUser; + // Gunakan nickname dari Firestore jika ada, jika tidak fallback ke displayName Auth atau default + final displayName = _userData?.nickname ?? user?.displayName ?? 'Pengguna'; + final firstName = displayName.split(' ').first; + + return Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 20, + left: 24, + right: 24, + bottom: 28, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF3C2415), // deep espresso + Color(0xFF6B3A2A), // rich brown + Color(0xFF8B5E3C), // warm brown + ], + stops: [0.0, 0.5, 1.0], + ), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(32), + bottomRight: Radius.circular(32), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Selamat Datang ๐Ÿ‘‹', + style: TextStyle( + color: const Color(0xFFD4C4B0), + fontSize: 13, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 4), + Text( + firstName, + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + ), + ], + ), + ), + _buildAvatar(user?.photoURL, firstName), + ], + ), + const SizedBox(height: 20), + // Action buttons row + Row( + children: [ + Expanded( + child: _buildHeaderAction( + icon: Icons.document_scanner_outlined, + label: 'Scan Menu', + sublabel: 'Foto menu kafe', + onTap: () => _navigateToScan(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildHeaderAction( + icon: Icons.smart_toy_outlined, + label: 'Tanya Seputar', + sublabel: 'Menu Amor Coffee', + onTap: () => _navigateToChat(), + ), + ), + ], + ), + ], + ), + ); + } + + void _navigateToScan() async { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const PhotoPickerScreen())); + _loadLatestRecommendations(); + } + + void _navigateToChat() { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AmorChatbotScreen(), + ), + ); + } + + Widget _buildHeaderAction({ + required IconData icon, + required String label, + required String sublabel, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.14), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.18)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFD4A574).withOpacity(0.25), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: const Color(0xFFD4A574), size: 18), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + Text( + sublabel, + style: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 11, + ), + ), + ], + ), + ), + Icon( + Icons.arrow_forward_ios_rounded, + color: Colors.white.withOpacity(0.4), + size: 12, + ), + ], + ), + ), + ); + } + + Widget _buildAvatar(String? photoURL, String firstName) { + return Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [Color(0xFFD4A574), Color(0xFFE8C9A8)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD4A574).withOpacity(0.4), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF3C2415), + ), + child: photoURL != null + ? ClipOval( + child: Image.network( + photoURL, + fit: BoxFit.cover, + width: 48, + height: 48, + errorBuilder: (_, __, ___) => _buildAvatarText(firstName), + ), + ) + : _buildAvatarText(firstName), + ), + ); + } + + Widget _buildAvatarText(String name) { + return Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : 'U', + style: const TextStyle( + color: Color(0xFFD4A574), + fontWeight: FontWeight.w800, + fontSize: 20, + ), + ), + ); + } + + Widget _buildQuickStats() { + final totalScans = _latestRecommendations.length; + final selectedCount = _latestRecommendations + .where((r) => r.selectedMenuName != null) + .length; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + child: Row( + children: [ + Expanded( + child: _buildStatCard( + icon: Icons.document_scanner_rounded, + value: '$totalScans', + label: 'Total Scan', + color: const Color(0xFF6B3A2A), + bgColor: const Color(0xFFFAF6F1), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.check_circle_rounded, + value: '$selectedCount', + label: 'Menu Dipilih', + color: const Color(0xFF4CAF50), + bgColor: const Color(0xFFF1F8F2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + icon: Icons.star_rounded, + value: _getAverageScore(), + label: 'Rata-rata', + color: const Color(0xFFE5A100), + bgColor: const Color(0xFFFFF8E8), + ), + ), + ], + ), + ); + } + + String _getAverageScore() { + if (_latestRecommendations.isEmpty) return '-'; + double total = 0; + int count = 0; + for (var rec in _latestRecommendations) { + if (rec.recommendations.isNotEmpty) { + total += rec.topRecommendations.first.score * 100; + count++; + } + } + if (count == 0) return '-'; + return '${(total / count).toStringAsFixed(0)}%'; + } + + Widget _buildStatCard({ + required IconData icon, + required String value, + required String label, + required Color color, + required Color bgColor, + }) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: color.withOpacity(0.1)), + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Icon(icon, color: color, size: 22), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Color(0xFF9E8E7E), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildSectionTitle(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 12), + child: Row( + children: [ + Container( + width: 4, + height: 22, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF8B5E3C), Color(0xFFD4A574)], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'Riwayat Scan Terbaru', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: Color(0xFF3C2415), + letterSpacing: -0.3, + ), + ), + ), + if (_latestRecommendations.isNotEmpty) ...[ + // Tombol hapus + GestureDetector( + onTap: () => _clearLatestRecommendations(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete_outline_rounded, + size: 14, + color: Colors.red.shade600, + ), + const SizedBox(width: 4), + Text( + 'Hapus', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.red.shade600, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + // Tombol lihat semua + GestureDetector( + onTap: () { + final homeState = context + .findAncestorStateOfType<_HomeScreenState>(); + homeState?._onTabTapped(1); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFF6B3A2A).withOpacity(0.08), + borderRadius: BorderRadius.circular(20), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Semua', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF6B3A2A), + ), + ), + SizedBox(width: 2), + Icon( + Icons.arrow_forward_ios_rounded, + size: 10, + color: Color(0xFF6B3A2A), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildShimmerLoading() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: List.generate(3, (i) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + height: 88, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF0EBE4), + borderRadius: BorderRadius.circular(14), + ), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: 140, + decoration: BoxDecoration( + color: const Color(0xFFF0EBE4), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + height: 10, + width: 100, + decoration: BoxDecoration( + color: const Color(0xFFF0EBE4), + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ], + ), + ); + }), + ), + ); + } + + Widget _buildEmptyState() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: const Color(0xFF8B5E3C).withOpacity(0.05), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + // Decorative icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF6B3A2A).withOpacity(0.08), + const Color(0xFFD4A574).withOpacity(0.12), + ], + ), + shape: BoxShape.circle, + ), + child: const Center( + child: Text('๐Ÿ“‹', style: TextStyle(fontSize: 36)), + ), + ), + const SizedBox(height: 20), + const Text( + 'Belum Ada Riwayat Scan', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: Color(0xFF3C2415), + ), + ), + const SizedBox(height: 8), + Text( + 'Tekan tombol scan di bawah untuk\nmemulai menemukan menu terbaik!', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: const Color(0xFF9E8E7E), + height: 1.6, + ), + ), + const SizedBox(height: 24), + // Decorative dots + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + 3, + (i) => Container( + width: 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFFD4A574).withOpacity(0.3 + i * 0.2), + ), + ), + ), + ), + ], + ), + ); + } + + SliverList _buildRecommendationsSliverList() { + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final rec = _latestRecommendations[index]; + MenuRecommendation? selectedMenu; + if (rec.selectedMenuName != null) { + selectedMenu = rec.recommendations + .cast() + .firstWhere( + (r) => r!.menuName == rec.selectedMenuName, + orElse: () => null, + ); + } + selectedMenu ??= rec.recommendations.isNotEmpty + ? rec.topRecommendations.first + : null; + if (selectedMenu == null) return const SizedBox.shrink(); + + // Wrap with Dismissible untuk swipe-to-delete + return Dismissible( + key: ValueKey(rec.id), + direction: DismissDirection.endToStart, + background: Container( + margin: EdgeInsets.only( + left: 20, + right: 20, + bottom: 10, + top: index == 0 ? 0 : 2, + ), + decoration: BoxDecoration( + color: Colors.red.shade500, + borderRadius: BorderRadius.circular(20), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.delete_rounded, color: Colors.white, size: 26), + SizedBox(height: 4), + Text( + 'Hapus', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + ), + ), + ], + ), + ), + confirmDismiss: (_) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + 'Hapus Riwayat Ini?', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.bold, + ), + ), + content: const Text( + 'Riwayat scan ini akan dihapus secara permanen.', + style: TextStyle(fontFamily: 'Poppins', fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text( + 'Batal', + style: TextStyle( + fontFamily: 'Poppins', + color: Colors.grey, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text( + 'Hapus', + style: TextStyle( + fontFamily: 'Poppins', + color: Colors.white, + ), + ), + ), + ], + ), + ); + }, + onDismissed: (_) => _deleteOneRecommendation(rec), + child: _buildPremiumCard(rec, selectedMenu, index), + ); + }, childCount: _latestRecommendations.length), + ); + } + + Future _deleteOneRecommendation(RecommendationModel rec) async { + try { + await FirebaseService().deleteRecommendation(rec.id); + if (mounted) { + setState( + () => _latestRecommendations.removeWhere((r) => r.id == rec.id), + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'Riwayat dihapus', + style: TextStyle(fontFamily: 'Poppins'), + ), + backgroundColor: Colors.green.shade600, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Gagal menghapus: $e', + style: const TextStyle(fontFamily: 'Poppins'), + ), + backgroundColor: Colors.red.shade600, + behavior: SnackBarBehavior.floating, + ), + ); + // Reload agar state tidak kacau + _loadLatestRecommendations(); + } + } + } + + Widget _buildPremiumCard( + RecommendationModel recommendation, + MenuRecommendation menu, + int index, + ) { + final isSelected = recommendation.selectedMenuName != null; + final score = (menu.score * 100).toInt(); + final categoryIcon = _getCategoryIcon(menu.category); + + return GestureDetector( + onTap: () { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (context) => + RecommendationScreen(recommendation: recommendation), + ), + ) + .then((_) => _loadLatestRecommendations()); + }, + child: Container( + margin: EdgeInsets.only( + left: 20, + right: 20, + bottom: 10, + top: index == 0 ? 0 : 2, + ), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? const Color(0xFFD4A574).withOpacity(0.3) + : const Color(0xFFF0EBE4), + ), + boxShadow: [ + BoxShadow( + color: isSelected + ? const Color(0xFF8B5E3C).withOpacity(0.08) + : Colors.black.withOpacity(0.03), + blurRadius: isSelected ? 16 : 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + // Icon with gradient + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isSelected + ? [const Color(0xFF8B5E3C), const Color(0xFF6B3A2A)] + : [const Color(0xFFE8DFD4), const Color(0xFFD4CFC8)], + ), + borderRadius: BorderRadius.circular(15), + boxShadow: isSelected + ? [ + BoxShadow( + color: const Color(0xFF8B5E3C).withOpacity(0.25), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : [], + ), + child: Icon( + categoryIcon, + color: isSelected ? Colors.white : const Color(0xFF8B7B6B), + size: 24, + ), + ), + const SizedBox(width: 14), + // Content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Menu name + badge + Row( + children: [ + Expanded( + child: Text( + menu.menuName, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: Color(0xFF3C2415), + letterSpacing: -0.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF43A047), Color(0xFF66BB6A)], + ), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'โœ“ Dipilih', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 0.3, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 6), + // Baris bawah: harga+skor kiri, tanggal kanan + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Kiri: harga ยท bintang skor + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + menu.formattedPrice, + style: const TextStyle( + color: Color(0xFF8B5E3C), + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + Container( + width: 3, + height: 3, + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: const BoxDecoration( + color: Color(0xFFD4CFC8), + shape: BoxShape.circle, + ), + ), + Icon( + Icons.star_rounded, + size: 14, + color: score >= 80 + ? const Color(0xFFE5A100) + : const Color(0xFFBDB5AB), + ), + const SizedBox(width: 2), + Text( + '$score%', + style: TextStyle( + fontSize: 12, + color: score >= 80 + ? const Color(0xFFE5A100) + : const Color(0xFF9E8E7E), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + // Kanan: tanggal + const SizedBox(width: 8), + Flexible( + child: Text( + recommendation.formattedDate, + style: const TextStyle( + fontSize: 11, + color: Color(0xFFBDB5AB), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + ), + ), + ], + ), + + ], + ), + ), + const SizedBox(width: 6), + // Tombol hapus per item + GestureDetector( + onTap: () async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + 'Hapus Riwayat Ini?', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.bold, + ), + ), + content: const Text( + 'Riwayat scan ini akan dihapus secara permanen.', + style: TextStyle(fontFamily: 'Poppins', fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text( + 'Batal', + style: TextStyle( + fontFamily: 'Poppins', + color: Colors.grey, + ), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + onPressed: () => Navigator.pop(ctx, true), + child: const Text( + 'Hapus', + style: TextStyle( + fontFamily: 'Poppins', + color: Colors.white, + ), + ), + ), + ], + ), + ); + if (confirm == true) _deleteOneRecommendation(recommendation); + }, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.delete_outline_rounded, + color: Colors.red.shade400, + size: 16, + ), + ), + ), + const SizedBox(width: 6), + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: const Color(0xFFF8F4EF), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.chevron_right_rounded, + color: Color(0xFFBDB5AB), + size: 18, + ), + ), + ], + ), + ), + ); + } + + IconData _getCategoryIcon(String category) { + final lower = category.toLowerCase(); + if (lower.contains('coffee') || lower.contains('kopi')) return Icons.coffee; + if (lower.contains('tea') || lower.contains('teh')) { + return Icons.emoji_food_beverage; + } + if (lower.contains('food') || lower.contains('makanan')) { + return Icons.restaurant; + } + if (lower.contains('drink') || lower.contains('minuman')) { + return Icons.local_drink; + } + if (lower.contains('dessert') || lower.contains('snack')) return Icons.cake; + return Icons.local_cafe; + } +} + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// OTHER TABS +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _HistoryTab extends ConsumerWidget { + const _HistoryTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return const HistoryScreen(initialIndex: 0); + } +} + +class _FavoritesTab extends ConsumerWidget { + const _FavoritesTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return const HistoryScreen(initialIndex: 1); + } +} + +class _ProfileTab extends ConsumerWidget { + const _ProfileTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return const ProfileScreen(); + } +} diff --git a/lib/screens/onboarding/daily_checkin_screen.dart b/lib/screens/onboarding/daily_checkin_screen.dart new file mode 100644 index 0000000..4f545a1 --- /dev/null +++ b/lib/screens/onboarding/daily_checkin_screen.dart @@ -0,0 +1,850 @@ +import 'package:flutter/material.dart'; +import '../../services/checkin_dataset.dart'; +import '../../services/firebase_service.dart'; +import '../../services/daily_checkin_service.dart'; + +// โ”€โ”€โ”€ Design tokens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const Color _brown = Color(0xFF4A3C3C); +const Color _lightBg = Color(0xFFFAF6F1); +const Color _accent = Color(0xFF8B5E3C); +const Color _grey = Color(0xFF9E9E9E); +const Color _border = Color(0xFFE8E8E8); + +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class DailyCheckinScreen extends StatefulWidget { + final bool isMandatory; + const DailyCheckinScreen({super.key, this.isMandatory = false}); + + @override + State createState() => _DailyCheckinScreenState(); +} + +class _DailyCheckinScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + bool _isSaving = false; + + // Page 1 โ€” dynamic + late final CheckinEntry _moodEntry; + late final CheckinEntry _cuacaEntry; + late final CheckinEntry _suasanaEntry; + + // All answers + final Map _answers = {}; + + // Budget slider state + double _budget = 30000; + bool _isLoadingData = false; + + @override + void initState() { + super.initState(); + final daily = CheckinDataset.getDailyEntries(); + _moodEntry = daily.mood; + _cuacaEntry = daily.cuaca; + _suasanaEntry = daily.suasana; + + _loadPreviousData(); + } + + Future _loadPreviousData() async { + setState(() => _isLoadingData = true); + try { + final user = FirebaseService().currentUser; + if (user != null) { + final userData = await FirebaseService().getUserData(user.uid); + if (userData != null) { + setState(() { + // Sinkronkan default budget dengan Preferensi Diri + _budget = userData.preferences.budgetRange.toDouble(); + _answers['budget'] = _budget.toInt(); + + if (userData.dailyPreferences != null) { + userData.dailyPreferences!.forEach((key, value) { + if (key == 'budget') { + _budget = (value as num).toDouble(); + _answers['budget'] = value; + } else { + _answers[key] = value.toString(); + } + }); + } + }); + } + } + } catch (e) { + debugPrint('Error loading previous preferences: $e'); + } finally { + if (mounted) setState(() => _isLoadingData = false); + } + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + bool get _page1Complete => + _answers.containsKey('mood') && + _answers.containsKey('cuaca') && + _answers.containsKey('suasana'); + + bool get _page2Complete => + _answers.containsKey('keinginan') && _answers.containsKey('budget'); + + bool get _page3Complete => _answers.containsKey('waktu'); + + void _goNext() { + if (_currentPage == 0 && !_page1Complete) { + _showValidation('Mohon jawab semua pertanyaan di halaman ini.'); + return; + } + if (_currentPage == 1 && !_page2Complete) { + _showValidation('Mohon pilih keinginan dan budget-mu.'); + return; + } + if (_currentPage < 2) { + _pageController.nextPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + } + } + + void _goPrev() { + if (_currentPage > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 350), + curve: Curves.easeInOut, + ); + } + } + + void _showValidation(String msg) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg, style: const TextStyle(fontFamily: 'Poppins')), + backgroundColor: _accent, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + + Future _submit() async { + if (!_page3Complete) { + _showValidation('Pilih waktu kunjunganmu dulu ya.'); + return; + } + setState(() => _isSaving = true); + try { + final user = FirebaseService().currentUser; + if (user == null) throw Exception('User not logged in'); + // Simpan budget sebagai int + _answers['budget'] = _budget.toInt(); + + // Simpan ke Daily Preferences + await DailyCheckinService().saveDailyPreferences(user.uid, _answers); + + // Sinkronkan juga ke Preferensi Diri (Master Profile) agar tetap sama + final userData = await FirebaseService().getUserData(user.uid); + if (userData != null) { + final updatedPrefs = userData.preferences.copyWith( + budgetRange: _budget.toInt(), + ); + await FirebaseService().updateUserPreferences(user.uid, updatedPrefs); + } + + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + debugPrint('Error saving check-in: $e'); + if (mounted) _showValidation('Gagal menyimpan: $e'); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + // โ”€โ”€ Build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _lightBg, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + _buildProgressDots(), + Expanded( + child: _isLoadingData + ? const Center(child: CircularProgressIndicator(color: _accent)) + : PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (i) => setState(() => _currentPage = i), + children: [_buildPage1(), _buildPage2(), _buildPage3()], + ), + ), + _buildNavBar(), + ], + ), + ), + ); + } + + // โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildHeader() { + final titles = [ + 'Gimana Hari Ini? ๐ŸŒค๏ธ', + 'Kamu Lagi Mau Apa? ๐Ÿฝ๏ธ', + 'Kapan Datangnya? ๐Ÿ•', + ]; + final subs = [ + 'Ceritakan mood, cuaca & suasanamu', + 'Pilih keinginan & budget-mu', + 'Waktu kunjungan membantu rekomendasi', + ]; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: [ + if (widget.isMandatory || _currentPage > 0) + GestureDetector( + onTap: _currentPage == 0 + ? () => Navigator.of(context).pop() + : _goPrev, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _border), + ), + child: const Icon( + Icons.arrow_back_ios_new, + size: 16, + color: _brown, + ), + ), + ) + else + const SizedBox(width: 36), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Text( + titles[_currentPage], + key: ValueKey(_currentPage), + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 18, + fontWeight: FontWeight.w700, + color: _brown, + ), + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Text( + subs[_currentPage], + key: ValueKey('s$_currentPage'), + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + color: _grey, + ), + ), + ), + ], + ), + ), + if (!widget.isMandatory && _currentPage == 0) + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text( + 'Lewati', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + color: _grey, + ), + ), + ), + ], + ), + ); + } + + // โ”€โ”€ Progress dots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildProgressDots() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (i) { + final active = i == _currentPage; + final done = i < _currentPage; + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: active ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: done + ? _accent.withOpacity(0.4) + : active + ? _accent + : _border, + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ), + ); + } + + // โ”€โ”€ Page 1: Mood + Cuaca + Suasana โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildPage1() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + children: [ + _buildDynamicCard( + icon: '๐Ÿ˜Š', + label: 'Mood', + entry: _moodEntry, + answerKey: 'mood', + valueList: CheckinDataset.moodValues, + ), + const SizedBox(height: 16), + _buildDynamicCard( + icon: '๐ŸŒค๏ธ', + label: 'Cuaca', + entry: _cuacaEntry, + answerKey: 'cuaca', + valueList: CheckinDataset.cuacaValues, + ), + const SizedBox(height: 16), + _buildDynamicCard( + icon: '๐Ÿ‘ฅ', + label: 'Suasana', + entry: _suasanaEntry, + answerKey: 'suasana', + valueList: CheckinDataset.suasanaValues, + ), + const SizedBox(height: 20), + ], + ), + ); + } + + Widget _buildDynamicCard({ + required String icon, + required String label, + required CheckinEntry entry, + required String answerKey, + required List valueList, + }) { + final selectedValue = _answers[answerKey]; + return _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(icon, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w600, + color: _accent, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + entry.question, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: _brown, + height: 1.4, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(entry.options.length, (i) { + final val = valueList[i]; + final selected = selectedValue == val; + return GestureDetector( + onTap: () => setState(() => _answers[answerKey] = val), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: selected ? _accent.withOpacity(0.12) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected ? _accent : _border, + width: selected ? 1.5 : 1, + ), + ), + child: Text( + entry.options[i], + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + color: selected ? _accent : _grey, + ), + ), + ), + ); + }), + ), + ], + ), + ); + } + + // โ”€โ”€ Page 2: Keinginan + Budget โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildPage2() { + final keinginanOptions = [ + ('๐Ÿฅค', 'Minuman', 'minuman'), + ('๐Ÿฝ๏ธ', 'Makanan', 'makanan'), + ('โ˜•๐Ÿฝ๏ธ', 'Keduanya', 'keduanya'), + ]; + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + children: [ + // Keinginan card + _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Text('๐Ÿ›’', style: TextStyle(fontSize: 20)), + SizedBox(width: 8), + Text( + 'KEINGINAN', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w600, + color: _accent, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Hari ini lagi mau apa?', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: _brown, + ), + ), + const SizedBox(height: 14), + Row( + children: keinginanOptions.map((opt) { + final (emoji, label, val) = opt; + final selected = _answers['keinginan'] == val; + return Expanded( + child: GestureDetector( + onTap: () => + setState(() => _answers['keinginan'] = val), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: selected + ? _accent.withOpacity(0.12) + : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? _accent : _border, + width: selected ? 1.5 : 1, + ), + ), + child: Column( + children: [ + Text(emoji, style: const TextStyle(fontSize: 24)), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w400, + color: selected ? _accent : _grey, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Budget card + _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Text('๐Ÿ’ฐ', style: TextStyle(fontSize: 20)), + SizedBox(width: 8), + Text( + 'BUDGET', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w600, + color: _accent, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Sekitar berapa budget-mu?', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: _brown, + ), + ), + const SizedBox(height: 16), + Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + decoration: BoxDecoration( + color: _accent.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _formatRp(_budget.toInt()), + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 20, + fontWeight: FontWeight.w700, + color: _accent, + ), + ), + ), + ), + const SizedBox(height: 8), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: _accent, + inactiveTrackColor: _border, + thumbColor: _accent, + overlayColor: _accent.withOpacity(0.1), + trackHeight: 4, + ), + child: Slider( + value: _budget, + min: 10000, + max: 200000, + divisions: 38, + onChanged: (v) => setState(() { + _budget = v; + _answers['budget'] = v.toInt(); + }), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Rp 10K', + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + color: _grey, + ), + ), + Text( + 'Rp 200K', + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + color: _grey, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 20), + ], + ), + ); + } + + // โ”€โ”€ Page 3: Waktu โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildPage3() { + final options = [ + ('๐ŸŒ…', 'Pagi', 'pagi', '06.00 โ€“ 11.00'), + ('โ˜€๏ธ', 'Siang', 'siang', '11.00 โ€“ 15.00'), + ('๐ŸŒค๏ธ', 'Sore', 'sore', '15.00 โ€“ 18.00'), + ('๐ŸŒ™', 'Malam', 'malam', '18.00 โ€“ 22.00'), + ]; + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: _card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Text('๐Ÿ•', style: TextStyle(fontSize: 20)), + SizedBox(width: 8), + Text( + 'WAKTU KUNJUNGAN', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w600, + color: _accent, + letterSpacing: 0.5, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Kapan kamu ke sini?', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: _brown, + ), + ), + const SizedBox(height: 16), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.1, + children: options.map((opt) { + final (emoji, label, val, time) = opt; + final selected = _answers['waktu'] == val; + return GestureDetector( + onTap: () => setState(() => _answers['waktu'] = val), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: selected + ? _accent.withOpacity(0.12) + : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selected ? _accent : _border, + width: selected ? 1.5 : 1, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(emoji, style: const TextStyle(fontSize: 26)), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + color: selected ? _accent : _brown, + ), + ), + Text( + time, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 10, + color: _grey, + ), + ), + ], + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + // โ”€โ”€ Bottom navigation bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _buildNavBar() { + final isLast = _currentPage == 2; + return Container( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + // Step indicator + Text( + '${_currentPage + 1} / 3', + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + color: _grey, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + // Next / Submit button + GestureDetector( + onTap: _isSaving ? null : (isLast ? _submit : _goNext), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14), + decoration: BoxDecoration( + color: _accent, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: _accent.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isLast ? 'Selesai' : 'Lanjut', + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(width: 6), + Icon( + isLast + ? Icons.check_rounded + : Icons.arrow_forward_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ), + ), + ], + ), + ); + } + + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + Widget _card({required Widget child}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: child, + ); + } + + String _formatRp(int value) { + final s = value.toString(); + final buf = StringBuffer('Rp '); + for (var i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write('.'); + buf.write(s[i]); + } + return buf.toString(); + } +} diff --git a/lib/screens/onboarding/early_setup_demographics_screen.dart b/lib/screens/onboarding/early_setup_demographics_screen.dart new file mode 100644 index 0000000..3a144e1 --- /dev/null +++ b/lib/screens/onboarding/early_setup_demographics_screen.dart @@ -0,0 +1,357 @@ +import 'package:flutter/material.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; +import 'early_setup_preferences_screen.dart'; + +// Colors matching the design +const Color primaryBrown = Color(0xFF4A3C3C); +const Color labelGray = Color(0xFF9E9E9E); +const Color borderGray = Color(0xFFE8E8E8); + +class EarlySetupDemographicsScreen extends StatefulWidget { + const EarlySetupDemographicsScreen({super.key}); + + @override + State createState() => + _EarlySetupDemographicsScreenState(); +} + +class _EarlySetupDemographicsScreenState + extends State { + bool _isLoading = false; + + // Options + final List _ageRanges = ['< 18', '18โ€“24', '25โ€“34', '35+']; + final List _genders = ['Male', 'Female', 'Prefer not to say']; + + String? _selectedAgeRange; + String? _selectedGender; + + @override + void initState() { + super.initState(); + _loadExistingData(); + } + + Future _loadExistingData() async { + try { + final user = FirebaseService().currentUser; + if (user != null) { + final userData = await FirebaseService().getUserData(user.uid); + if (userData != null && mounted) { + setState(() { + _selectedGender = userData.gender; + // Load age range if stored in preferences + final ageRange = userData.preferences.ageRange; + if (ageRange != null && _ageRanges.contains(ageRange)) { + _selectedAgeRange = ageRange; + } + }); + } + } + } catch (e) { + debugPrint('Error loading data: $e'); + } + } + + Future _handleContinue() async { + if (_selectedAgeRange == null) { + DialogService().showError('Silakan pilih rentang usia'); + return; + } + if (_selectedGender == null) { + DialogService().showError('Silakan pilih gender'); + return; + } + + setState(() => _isLoading = true); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) throw Exception('User not logged in'); + + final userData = await firebaseService.getUserData(currentUser.uid); + if (userData != null) { + // Update gender in user model + final updatedUser = userData.copyWith(gender: _selectedGender); + await firebaseService.updateUserData(updatedUser); + + // Update age range in preferences + final updatedPreferences = userData.preferences.copyWith( + ageRange: _selectedAgeRange, + ); + await firebaseService.updateUserPreferences( + currentUser.uid, + updatedPreferences, + ); + + if (mounted) { + // Navigate to next screen + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const EarlySetupPreferencesScreen(), + ), + ); + + if (mounted) { + Navigator.of(context).pop(true); + } + } + } + } catch (e) { + if (mounted) { + DialogService().showError('Gagal menyimpan: $e'); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // Back button + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: IconButton( + icon: const Icon( + Icons.chevron_left, + color: Colors.black54, + size: 28, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + + // Title + const Text( + 'Tell us about\nyourself', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 26, + fontWeight: FontWeight.bold, + color: primaryBrown, + height: 1.25, + ), + ), + const SizedBox(height: 10), + const Text( + 'This helps personalize your experience', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + + const SizedBox(height: 40), + + // Age Range Section + _buildSectionTitle('Age Range', 'Pilih rentang usia Anda'), + const SizedBox(height: 12), + _buildAgeRangeChips(), + + const SizedBox(height: 32), + + // Gender Section + _buildSectionTitle('Gender', 'Pilih jenis kelamin Anda'), + const SizedBox(height: 12), + _buildGenderChips(), + + const SizedBox(height: 48), + + // Continue Button + Align( + alignment: Alignment.centerRight, + child: _buildCircularButton( + onPressed: _isLoading ? null : _handleContinue, + isLoading: _isLoading, + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSectionTitle(String title, String subtitle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + ], + ); + } + + Widget _buildAgeRangeChips() { + return Wrap( + spacing: 10, + runSpacing: 10, + children: _ageRanges.map((ageRange) { + final isSelected = _selectedAgeRange == ageRange; + return ChoiceChip( + label: Text(ageRange), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _selectedAgeRange = ageRange; + }); + } + }, + selectedColor: primaryBrown.withOpacity(0.15), + backgroundColor: Colors.grey[100], + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? primaryBrown : labelGray, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: isSelected ? primaryBrown : borderGray, + width: 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ); + }).toList(), + ); + } + + Widget _buildGenderChips() { + return Wrap( + spacing: 10, + runSpacing: 10, + children: _genders.map((gender) { + final isSelected = _selectedGender == gender; + return ChoiceChip( + label: Text(gender), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _selectedGender = gender; + }); + } + }, + selectedColor: primaryBrown.withOpacity(0.15), + backgroundColor: Colors.grey[100], + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? primaryBrown : labelGray, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: isSelected ? primaryBrown : borderGray, + width: 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ); + }).toList(), + ); + } + + Widget _buildCircularButton({ + VoidCallback? onPressed, + bool isLoading = false, + }) { + return Stack( + clipBehavior: Clip.none, + children: [ + // Shadow overlay + Positioned( + top: -4, + left: 4, + right: -4, + bottom: 4, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF5A4A3A).withOpacity(0.3), + shape: BoxShape.circle, + ), + ), + ), + // Main button + Material( + color: const Color(0xFF5A4A3A), + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration(shape: BoxShape.circle), + child: isLoading + ? const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : const Icon( + Icons.arrow_forward, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/onboarding/early_setup_form_screen.dart b/lib/screens/onboarding/early_setup_form_screen.dart new file mode 100644 index 0000000..0a26cd0 --- /dev/null +++ b/lib/screens/onboarding/early_setup_form_screen.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; +import 'early_setup_preferences_screen.dart'; + +// Colors matching the design +const Color primaryBrown = Color(0xFF4A3C3C); +const Color labelGray = Color(0xFF9E9E9E); +const Color borderGray = Color(0xFFE8E8E8); + +class EarlySetupFormScreen extends StatefulWidget { + const EarlySetupFormScreen({super.key}); + + @override + State createState() => _EarlySetupFormScreenState(); +} + +class _EarlySetupFormScreenState extends State { + final _formKey = GlobalKey(); + + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _nicknameController = TextEditingController(); + final TextEditingController _addressController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); + + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + @override + void dispose() { + _fullNameController.dispose(); + _nicknameController.dispose(); + _addressController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + Future _loadUserData() async { + try { + final user = FirebaseService().currentUser; + if (user != null) { + _fullNameController.text = user.displayName ?? ''; + + final userData = await FirebaseService().getUserData(user.uid); + if (userData != null) { + if (userData.nickname != null) { + _nicknameController.text = userData.nickname!; + } + if (userData.address != null) { + _addressController.text = userData.address!; + } + if (userData.phoneNumber != null) { + _phoneController.text = userData.phoneNumber!; + } + } + } + } catch (e) { + debugPrint('Error loading user data: $e'); + } + } + + Future _handleContinue() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) throw Exception('User not logged in'); + + final existingUser = await firebaseService.getUserData(currentUser.uid); + + if (existingUser != null) { + final updatedUser = existingUser.copyWith( + displayName: _fullNameController.text.trim(), + nickname: _nicknameController.text.trim(), + address: _addressController.text.trim(), + phoneNumber: _phoneController.text.trim(), + ); + + await firebaseService.updateUserData(updatedUser); + } else { + await firebaseService.createUserDocument( + currentUser, + name: _fullNameController.text.trim(), + ); + } + + if (mounted) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const EarlySetupPreferencesScreen(), + ), + ); + + if (mounted) { + Navigator.of(context).pop(true); + } + } + } catch (e) { + if (mounted) DialogService().showError('Failed to save profile: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // Back button + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: IconButton( + icon: const Icon( + Icons.chevron_left, + color: Colors.black54, + size: 28, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + + // Title + const Text( + 'Please complete the\nform below', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 26, + fontWeight: FontWeight.bold, + color: primaryBrown, + height: 1.25, + ), + ), + const SizedBox(height: 10), + const Text( + 'With Your Personal Information', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + + const SizedBox(height: 28), + + // Full Name Field + _buildTextField( + controller: _fullNameController, + label: 'Full Name', + hint: 'Masukkan nama lengkap', + ), + + const SizedBox(height: 14), + + // Nickname Field + _buildTextField( + controller: _nicknameController, + label: 'Nickname', + hint: 'Masukkan nama panggilan', + ), + + const SizedBox(height: 14), + + // Address Field - Taller + _buildTextField( + controller: _addressController, + label: 'Address', + hint: 'Masukkan alamat lengkap', + minHeight: 100, + maxLines: 4, + ), + + const SizedBox(height: 14), + + // Phone Number Field + _buildTextField( + controller: _phoneController, + label: 'Phone Number', + hint: 'Contoh: 08123456789', + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(15), + ], + ), + + const SizedBox(height: 48), + + // Continue Button (Circular Arrow) + Align( + alignment: Alignment.centerRight, + child: _buildCircularButton( + onPressed: _isLoading ? null : _handleContinue, + isLoading: _isLoading, + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + String? hint, + double minHeight = 56, + int maxLines = 1, + TextInputType keyboardType = TextInputType.text, + List? inputFormatters, + }) { + return Builder( + builder: (context) { + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderGray, width: 1), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Label + Text( + label, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + const SizedBox(height: 4), + // Text input + TextFormField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + onTap: () { + Future.delayed(const Duration(milliseconds: 300), () { + if (context.mounted) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + }, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintText: hint, + hintStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: labelGray.withOpacity(0.6), + ), + ), + validator: (v) => v!.isEmpty ? '$label is required' : null, + ), + ], + ), + ); + }, + ); + } + + Widget _buildCircularButton({ + VoidCallback? onPressed, + bool isLoading = false, + }) { + return Stack( + clipBehavior: Clip.none, + children: [ + // Shadow overlay + Positioned( + top: -4, + left: 4, + right: -4, + bottom: 4, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF5A4A3A).withOpacity(0.3), + shape: BoxShape.circle, + ), + ), + ), + // Main button + Material( + color: const Color(0xFF5A4A3A), + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration(shape: BoxShape.circle), + child: isLoading + ? const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : const Icon( + Icons.arrow_forward, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/onboarding/early_setup_preferences_screen.dart b/lib/screens/onboarding/early_setup_preferences_screen.dart new file mode 100644 index 0000000..4db24ea --- /dev/null +++ b/lib/screens/onboarding/early_setup_preferences_screen.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; +import '../../models/user_model.dart'; + +// Colors matching the design +const Color primaryBrown = Color(0xFF4A3C3C); +const Color labelGray = Color(0xFF9E9E9E); +const Color borderGray = Color(0xFFE8E8E8); + +class EarlySetupPreferencesScreen extends StatefulWidget { + const EarlySetupPreferencesScreen({super.key}); + + @override + State createState() => + _EarlySetupPreferencesScreenState(); +} + +class _EarlySetupPreferencesScreenState + extends State { + bool _isLoading = false; + + // Available options + final List _availableAllergies = [ + 'Gluten', + 'Kacang', + 'Susu', + 'Telur', + 'Seafood', + 'Kedelai', + 'Wijen', + 'Sulfit', + ]; + + final List _availableDietaryRestrictions = [ + 'None', + 'Vegetarian', + 'Vegan', + 'Halal', + 'Keto', + 'Low Carb', + ]; + + List _selectedAllergies = []; + String _selectedDietaryRestriction = 'None'; + + @override + void initState() { + super.initState(); + _loadExistingPreferences(); + } + + Future _loadExistingPreferences() async { + try { + final user = FirebaseService().currentUser; + if (user != null) { + final userData = await FirebaseService().getUserData(user.uid); + if (userData != null && mounted) { + setState(() { + _selectedAllergies = userData.preferences.allergies; + _selectedDietaryRestriction = + userData.preferences.dietaryRestriction; + }); + } + } + } catch (e) { + debugPrint('Error loading preferences: $e'); + } + } + + Future _handleContinue() async { + setState(() => _isLoading = true); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) throw Exception('User not logged in'); + + final userData = await firebaseService.getUserData(currentUser.uid); + if (userData != null) { + final updatedPreferences = userData.preferences.copyWith( + allergies: _selectedAllergies, + dietaryRestriction: _selectedDietaryRestriction, + ); + + await firebaseService.updateUserPreferences( + currentUser.uid, + updatedPreferences, + ); + + if (mounted) { + // Return true to indicate completion + Navigator.of(context).pop(true); + } + } + } catch (e) { + if (mounted) { + DialogService().showError('Failed to save preferences: $e'); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // Back button + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: IconButton( + icon: const Icon( + Icons.chevron_left, + color: Colors.black54, + size: 28, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + + // Title + const Text( + 'Set your dietary\npreferences', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 26, + fontWeight: FontWeight.bold, + color: primaryBrown, + height: 1.25, + ), + ), + const SizedBox(height: 10), + const Text( + 'Help us recommend safer options for you', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + + const SizedBox(height: 32), + + // Allergies Section + _buildSectionTitle( + 'Alergi & Pantangan', + 'Pilih bahan yang harus dihindari', + ), + const SizedBox(height: 12), + _buildAllergiesChips(), + + const SizedBox(height: 28), + + // Dietary Restrictions Section + _buildSectionTitle( + 'Pembatasan Diet', + 'Pilih pola makan yang Anda ikuti', + ), + const SizedBox(height: 12), + _buildDietaryRestrictionsChips(), + + const SizedBox(height: 48), + + // Continue Button + Align( + alignment: Alignment.centerRight, + child: _buildCircularButton( + onPressed: _isLoading ? null : _handleContinue, + isLoading: _isLoading, + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSectionTitle(String title, String subtitle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + ], + ); + } + + Widget _buildAllergiesChips() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: _availableAllergies.map((allergy) { + final isSelected = _selectedAllergies.contains(allergy); + return FilterChip( + label: Text(allergy), + selected: isSelected, + onSelected: (selected) { + setState(() { + if (selected) { + _selectedAllergies.add(allergy); + } else { + _selectedAllergies.remove(allergy); + } + }); + }, + selectedColor: const Color(0xFFFFEBEE), + checkmarkColor: const Color(0xFFD32F2F), + backgroundColor: Colors.grey[100], + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? const Color(0xFFD32F2F) : primaryBrown, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: isSelected ? const Color(0xFFD32F2F) : borderGray, + width: 1, + ), + ), + ); + }).toList(), + ); + } + + Widget _buildDietaryRestrictionsChips() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: _availableDietaryRestrictions.map((restriction) { + final isSelected = _selectedDietaryRestriction == restriction; + return ChoiceChip( + label: Text(restriction), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _selectedDietaryRestriction = restriction; + }); + } + }, + selectedColor: primaryBrown.withOpacity(0.15), + backgroundColor: Colors.grey[100], + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? primaryBrown : labelGray, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: isSelected ? primaryBrown : borderGray, + width: 1, + ), + ), + ); + }).toList(), + ); + } + + Widget _buildCircularButton({ + VoidCallback? onPressed, + bool isLoading = false, + }) { + return Stack( + clipBehavior: Clip.none, + children: [ + // Shadow overlay + Positioned( + top: -4, + left: 4, + right: -4, + bottom: 4, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF5A4A3A).withOpacity(0.3), + shape: BoxShape.circle, + ), + ), + ), + // Main button + Material( + color: const Color(0xFF5A4A3A), + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration(shape: BoxShape.circle), + child: isLoading + ? const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : const Icon( + Icons.arrow_forward, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/onboarding/early_setup_screen.dart b/lib/screens/onboarding/early_setup_screen.dart new file mode 100644 index 0000000..0932964 --- /dev/null +++ b/lib/screens/onboarding/early_setup_screen.dart @@ -0,0 +1,641 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; +import '../home/home_screen.dart'; +import '../auth/login_screen.dart'; +import '../../models/user_model.dart'; + +// Colors matching the design +const Color primaryBrown = Color(0xFF4A3C3C); +const Color labelGray = Color(0xFF9E9E9E); +const Color borderGray = Color(0xFFE8E8E8); + +class EarlySetupScreen extends StatefulWidget { + const EarlySetupScreen({super.key}); + + @override + State createState() => _EarlySetupScreenState(); +} + +class _EarlySetupScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + bool _isLoading = false; + + // Form controllers + final _nicknameController = TextEditingController(); + final _addressController = TextEditingController(); + + // Demographics selections + String? _selectedAgeRange; + String? _selectedGender; + + // Preferences selections + List _selectedAllergies = []; + String _selectedDietaryRestriction = 'None'; + + // Questionnaire state removed โ€” daily check-in questions + // no longer shown during onboarding. + + // Options + final List _ageRanges = ['< 18', '18โ€“24', '25โ€“34', '35+']; + final List _genders = ['Laki-laki', 'Perempuan', 'Rahasiakan']; + final List _availableAllergies = [ + 'Gluten', + 'Kacang', + 'Susu', + 'Telur', + 'Seafood', + 'Kedelai', + 'Wijen', + 'Sulfit', + ]; + final List _availableDietaryRestrictions = [ + 'None', + 'Vegetarian', + 'Vegan', + 'Halal', + 'Keto', + 'Low Carb', + ]; + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + @override + void dispose() { + _pageController.dispose(); + _nicknameController.dispose(); + _addressController.dispose(); + super.dispose(); + } + + Future _loadUserData() async { + try { + final user = FirebaseService().currentUser; + if (user != null) { + final userData = await FirebaseService().getUserData(user.uid); + if (userData != null && mounted) { + setState(() { + if (userData.nickname != null) + _nicknameController.text = userData.nickname!; + if (userData.address != null) + _addressController.text = userData.address!; + if (userData.gender != null) _selectedGender = userData.gender; + _selectedAllergies = userData.preferences.allergies; + _selectedDietaryRestriction = + userData.preferences.dietaryRestriction; + if (userData.preferences.ageRange != null) { + _selectedAgeRange = userData.preferences.ageRange; + } + }); + } + } + } catch (e) { + debugPrint('Error loading user data: $e'); + } + } + + void _nextPage() { + // Page 0: Demographics Validation + if (_currentPage == 0) { + if (_nicknameController.text.trim().isEmpty || + _addressController.text.trim().isEmpty || + _selectedAgeRange == null || + _selectedGender == null) { + DialogService().showError('Silakan lengkapi semua data profil'); + return; + } + } + + // Page 1 (Preferensi) โ€” lanjut selesai setup + if (_currentPage == 1) { + _handleComplete(); + return; + } + + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + void _previousPage() async { + if (_currentPage > 0) { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + await FirebaseService().signOut(); + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + } + } + + Future _handleComplete() async { + setState(() => _isLoading = true); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) throw Exception('User not logged in'); + + final existingUser = await firebaseService.getUserData(currentUser.uid); + + final nickname = _nicknameController.text.trim(); + final address = _addressController.text.trim(); + + // (questionnaire answers no longer saved during onboarding) + + if (existingUser != null) { + final updatedUser = existingUser.copyWith( + nickname: nickname.isNotEmpty ? nickname : null, + address: address.isNotEmpty ? address : null, + gender: _selectedGender, + isSetupComplete: true, + ); + await firebaseService.updateUserData(updatedUser); + } else { + final newUser = await firebaseService.createUserDocument(currentUser); + final updatedNewUser = newUser.copyWith( + nickname: nickname.isNotEmpty ? nickname : null, + address: address.isNotEmpty ? address : null, + gender: _selectedGender, + isSetupComplete: true, + ); + await firebaseService.updateUserData(updatedNewUser); + } + + final currentPrefs = + existingUser?.preferences ?? + UserPreferences( + allergies: [], + dietaryRestriction: 'None', + ageRange: null, + ); + + final updatedPreferences = currentPrefs.copyWith( + ageRange: _selectedAgeRange, + allergies: _selectedAllergies, + dietaryRestriction: _selectedDietaryRestriction, + ); + + await firebaseService.updateUserPreferences( + currentUser.uid, + updatedPreferences, + ); + + await firebaseService.setSetupComplete(currentUser.uid); + + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const HomeScreen()), + ); + } + } catch (e) { + if (mounted) DialogService().showError('Gagal menyimpan: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _previousPage(); + }, + child: Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // Header + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: IconButton( + icon: const Icon( + Icons.chevron_left, + color: Colors.black54, + size: 28, + ), + onPressed: _previousPage, + ), + ), + ), + + // Title Section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + children: [ + const SizedBox(height: 8), + const Text( + 'Kenalan Dulu Yuk!', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 26, + fontWeight: FontWeight.bold, + color: primaryBrown, + height: 1.25, + ), + ), + const SizedBox(height: 10), + Text( + _getSubtitle(), + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + const SizedBox(height: 8), + _buildProgressIndicator(), + ], + ), + ), + + const SizedBox(height: 24), + + // PageView + Expanded( + child: PageView( + controller: _pageController, + physics: const NeverScrollableScrollPhysics(), + onPageChanged: (index) { + setState(() => _currentPage = index); + }, + children: [_buildDemographicsPage(), _buildPreferencesPage()], + ), + ), + ], + ), + ), + ), + ); + } + + String _getSubtitle() { + switch (_currentPage) { + case 0: + return 'Data diri dasar kamu'; + case 1: + return 'Info alergi & pantangan'; + default: + return ''; + } + } + + Widget _buildProgressIndicator() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(2, (index) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: index == _currentPage ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: index == _currentPage ? primaryBrown : borderGray, + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ); + } + + // ============ PAGE 1: Profile & Demographics ============ + Widget _buildDemographicsPage() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField( + controller: _nicknameController, + label: 'Nama Panggilan', + hint: 'Bagaimana kami harus memanggil Anda?', + ), + const SizedBox(height: 24), + _buildTextField( + controller: _addressController, + label: 'Alamat Lengkap', + hint: 'Domisili Anda saat ini', + maxLines: 2, + minHeight: 80, + keyboardType: TextInputType.streetAddress, + ), + const SizedBox(height: 24), + _buildSectionTitle('Rentang Usia', 'Pilih rentang usia Anda'), + const SizedBox(height: 12), + _buildChoiceChips( + options: _ageRanges, + selectedValue: _selectedAgeRange, + onSelected: (value) => setState(() => _selectedAgeRange = value), + ), + const SizedBox(height: 24), + _buildSectionTitle('Jenis Kelamin', 'Pilih jenis kelamin Anda'), + const SizedBox(height: 12), + _buildChoiceChips( + options: _genders, + selectedValue: _selectedGender, + onSelected: (value) => setState(() => _selectedGender = value), + ), + const SizedBox(height: 48), + _buildNavigationButton(), + const SizedBox(height: 32), + ], + ), + ); + } + + // ============ PAGE 3: Preferences ============ + Widget _buildPreferencesPage() { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle( + 'Alergi & Pantangan', + 'Pilih bahan yang harus dihindari', + ), + const SizedBox(height: 12), + _buildFilterChips(), + const SizedBox(height: 28), + _buildSectionTitle( + 'Pembatasan Diet', + 'Pilih pola makan yang Anda ikuti', + ), + const SizedBox(height: 12), + _buildChoiceChips( + options: _availableDietaryRestrictions, + selectedValue: _selectedDietaryRestriction, + onSelected: (value) => + setState(() => _selectedDietaryRestriction = value), + ), + const SizedBox(height: 48), + _buildNavigationButton(), + const SizedBox(height: 32), + ], + ), + ); + } + + // ============ REUSABLE WIDGETS ============ + Widget _buildTextField({ + required TextEditingController controller, + required String label, + String? hint, + double minHeight = 56, + int maxLines = 1, + TextInputType keyboardType = TextInputType.text, + List? inputFormatters, + }) { + return Builder( + builder: (context) { + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderGray, width: 1), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + const SizedBox(height: 4), + TextFormField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + onTap: () { + Future.delayed(const Duration(milliseconds: 300), () { + if (context.mounted) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + }, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + decoration: InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + hintText: hint, + hintStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, + color: labelGray.withOpacity(0.6), + ), + ), + validator: (v) => v!.isEmpty ? '$label is required' : null, + ), + ], + ), + ); + }, + ); + } + + Widget _buildSectionTitle(String title, String subtitle) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 12, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + ], + ); + } + + Widget _buildChoiceChips({ + required List options, + required String? selectedValue, + required Function(String) onSelected, + }) { + return Wrap( + spacing: 10, + runSpacing: 10, + children: options.map((option) { + final isSelected = selectedValue == option; + return ChoiceChip( + label: Text(option), + selected: isSelected, + onSelected: (selected) { + if (selected) onSelected(option); + }, + selectedColor: primaryBrown, + backgroundColor: Colors.grey[100], + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? Colors.white : labelGray, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: isSelected ? primaryBrown : borderGray, + width: 1, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ); + }).toList(), + ); + } + + Widget _buildFilterChips() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: _availableAllergies.map((allergy) { + final isSelected = _selectedAllergies.contains(allergy); + return FilterChip( + label: Text(allergy), + selected: isSelected, + onSelected: (selected) { + setState(() { + if (selected) { + _selectedAllergies.add(allergy); + } else { + _selectedAllergies.remove(allergy); + } + }); + }, + selectedColor: const Color(0xFFFFEBEE), + checkmarkColor: const Color(0xFFD32F2F), + backgroundColor: Colors.grey[100], + labelStyle: TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? const Color(0xFFD32F2F) : primaryBrown, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: isSelected ? const Color(0xFFD32F2F) : borderGray, + width: 1, + ), + ), + ); + }).toList(), + ); + } + + Widget _buildNavigationButton() { + return Align( + alignment: Alignment.centerRight, + child: _buildCircularButton( + onPressed: _isLoading ? null : _nextPage, + isLoading: _isLoading, + isComplete: _currentPage == 1, // Page 1 = preferensi = halaman terakhir + ), + ); + } + + Widget _buildCircularButton({ + VoidCallback? onPressed, + bool isLoading = false, + bool isComplete = false, + }) { + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: -4, + left: 4, + right: -4, + bottom: 4, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF5A4A3A).withOpacity(0.3), + shape: BoxShape.circle, + ), + ), + ), + Material( + color: const Color(0xFF5A4A3A), + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration(shape: BoxShape.circle), + child: isLoading + ? const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : Icon( + isComplete ? Icons.check : Icons.arrow_forward, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/onboarding/profile_setup_screen.dart b/lib/screens/onboarding/profile_setup_screen.dart new file mode 100644 index 0000000..fbe7e68 --- /dev/null +++ b/lib/screens/onboarding/profile_setup_screen.dart @@ -0,0 +1,558 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:io'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; +import '../../models/user_model.dart'; +import '../profile/preferences_screen.dart'; + +// Colors matching the design +const Color primaryBrown = Color(0xFF4A3C3C); +const Color labelGray = Color(0xFF9E9E9E); +const Color borderGray = Color(0xFFE8E8E8); +const Color buttonGradientStart = Color(0xFF9E8E8E); +const Color buttonGradientEnd = Color(0xFFB8A8A8); + +class ProfileSetupScreen extends StatefulWidget { + const ProfileSetupScreen({super.key}); + + @override + State createState() => _ProfileSetupScreenState(); +} + +class _ProfileSetupScreenState extends State { + final _formKey = GlobalKey(); + + final TextEditingController _fullNameController = TextEditingController(); + final TextEditingController _nicknameController = TextEditingController(); + final TextEditingController _addressController = TextEditingController(); + + String? _selectedGender; + File? _profileImage; + String? _profileImageName; + bool _isLoading = false; + + final List _genders = ['Male', 'Female']; + final ImagePicker _picker = ImagePicker(); + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + Future _loadUserData() async { + try { + final user = FirebaseService().currentUser; + if (user != null) { + _fullNameController.text = user.displayName ?? ''; + + final userData = await FirebaseService().getUserData(user.uid); + if (userData != null) { + if (userData.nickname != null) + _nicknameController.text = userData.nickname!; + if (userData.gender != null) _selectedGender = userData.gender; + if (userData.address != null) + _addressController.text = userData.address!; + } + } + } catch (e) { + debugPrint('Error loading user data: $e'); + } + } + + Future _pickImage() async { + try { + final XFile? image = await _picker.pickImage(source: ImageSource.gallery); + if (image != null) { + setState(() { + _profileImage = File(image.path); + _profileImageName = image.name; + }); + } + } catch (e) { + DialogService().showError('Failed to pick image: $e'); + } + } + + Future _handleContinue() async { + if (!_formKey.currentState!.validate()) return; + + if (_selectedGender == null) { + DialogService().showError('Please select your gender'); + return; + } + + setState(() => _isLoading = true); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) throw Exception('User not logged in'); + + final existingUser = await firebaseService.getUserData(currentUser.uid); + + if (existingUser != null) { + final updatedUser = existingUser.copyWith( + displayName: _fullNameController.text.trim(), + nickname: _nicknameController.text.trim(), + gender: _selectedGender, + address: _addressController.text.trim(), + ); + + await firebaseService.updateUserData(updatedUser); + } else { + await firebaseService.createUserDocument( + currentUser, + name: _fullNameController.text.trim(), + ); + } + + if (mounted) { + final userData = await firebaseService.getUserData(currentUser.uid); + final prefs = userData?.preferences ?? UserPreferences(); + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PreferencesScreen( + userPreferences: prefs, + onPreferencesUpdated: (newPrefs) {}, + ), + ), + ); + + if (mounted) { + Navigator.of(context).pop(true); + } + } + } catch (e) { + if (mounted) DialogService().showError('Failed to save profile: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // Back button + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 8, top: 8), + child: IconButton( + icon: const Icon( + Icons.chevron_left, + color: Colors.black54, + size: 28, + ), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + + // Scrollable content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + + // Title - Italic serif style + Text( + 'Please complete the\nform below', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 26, + fontWeight: FontWeight.bold, + color: primaryBrown.withOpacity(0.85), + height: 1.25, + ), + ), + const SizedBox(height: 10), + Text( + 'With Your Personal Information', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 20, + fontWeight: FontWeight.w600, + color: primaryBrown.withOpacity(0.85), + ), + ), + + const SizedBox(height: 28), + + // Full Name Field + _buildTextField( + controller: _fullNameController, + label: 'Full Name', + ), + + const SizedBox(height: 14), + + // Nickname Field + _buildTextField( + controller: _nicknameController, + label: 'Nickname', + ), + + const SizedBox(height: 14), + + // Gender Dropdown - Smaller width (Align prevents stretch) + Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 150, + child: _buildGenderDropdown(), + ), + ), + + const SizedBox(height: 14), + + // Address Field - Taller + _buildTextField( + controller: _addressController, + label: 'Address', + minHeight: 100, + maxLines: 4, + ), + + const SizedBox(height: 20), + + // Profile Picture Row + _buildProfilePictureRow(), + + const SizedBox(height: 32), + + // Continue Button (Circular Arrow) + Align( + alignment: Alignment.centerRight, + child: _buildCircularButton( + onPressed: _isLoading ? null : _handleContinue, + isLoading: _isLoading, + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTextField({ + required TextEditingController controller, + required String label, + double minHeight = 56, + int maxLines = 1, + }) { + return Builder( + builder: (context) { + return Container( + constraints: BoxConstraints(minHeight: minHeight), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderGray, width: 2), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Label + Text( + label, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + const SizedBox(height: 4), + // Text input + TextFormField( + controller: controller, + maxLines: maxLines, + onTap: () { + Future.delayed(const Duration(milliseconds: 300), () { + if (context.mounted) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + }, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + validator: (v) => v!.isEmpty ? '$label is required' : null, + ), + ], + ), + ); + }, + ); + } + + Widget _buildGenderDropdown() { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderGray, width: 1), + ), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Label + const Text( + 'Gender', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + const SizedBox(height: 4), + // Dropdown + DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedGender, + isDense: true, + isExpanded: true, + dropdownColor: Colors.white, // Fix: White background for menu + borderRadius: BorderRadius.circular(12), // Rounded menu corners + icon: const Icon( + Icons.keyboard_arrow_down, + color: labelGray, + size: 20, + ), + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w600, + color: primaryBrown, // Text color inside dropdown + ), + hint: const Text( + '', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: labelGray, + ), + ), + items: _genders.map((String gender) { + return DropdownMenuItem( + value: gender, + child: Text( + gender, + style: const TextStyle( + color: primaryBrown, // Explicit text color for items + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + onChanged: (String? newValue) { + setState(() { + _selectedGender = newValue; + }); + }, + ), + ), + ], + ), + ); + } + + Widget _buildProfilePictureRow() { + return Row( + children: [ + // Image thumbnail + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + image: _profileImage != null + ? DecorationImage( + image: FileImage(_profileImage!), + fit: BoxFit.cover, + ) + : null, + ), + child: _profileImage == null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset( + 'assets/images/EarlySetup.png', + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.person, + color: Colors.grey, + size: 24, + ); + }, + ), + ) + : null, + ), + const SizedBox(width: 12), + + // Profile info container + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + border: Border.all(color: borderGray, width: 1), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Profile Picture', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + const SizedBox(height: 2), + Text( + _profileImageName ?? 'Select Image', + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 13, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // Upload button + TextButton( + onPressed: _pickImage, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: labelGray.withOpacity(0.5)), + ), + ), + child: const Text( + 'Upload', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w400, + color: labelGray, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildCircularButton({ + VoidCallback? onPressed, + bool isLoading = false, + }) { + return Stack( + clipBehavior: Clip.none, + children: [ + // Shadow overlay + Positioned( + top: -4, + left: 4, + right: -4, + bottom: 4, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF5A4A3A).withOpacity(0.3), + shape: BoxShape.circle, + ), + ), + ), + // Main button + Material( + color: const Color(0xFF5A4A3A), + shape: const CircleBorder(), + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: Container( + width: 64, + height: 64, + decoration: const BoxDecoration(shape: BoxShape.circle), + child: isLoading + ? const Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ) + : const Icon( + Icons.arrow_forward, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart new file mode 100644 index 0000000..67e1ccc --- /dev/null +++ b/lib/screens/onboarding_screen.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'auth/login_screen.dart'; + +class OnboardingScreen extends StatefulWidget { + const OnboardingScreen({super.key}); + + @override + State createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List _pages = [ + OnboardingData( + image: 'assets/images/onboard1.png', + title: 'Bidik Menumu, Cepat dan Akurat', + description: + 'Teknologi Google Vision OCR kami akan membaca daftar menu Amor Coffee secara instan dalam hitungan detik', + ), + OnboardingData( + image: 'assets/images/onboard2.png', + title: 'Beri Tahu Amori Seleramu', + description: + 'Apakah kamu sedang butuh kafein tinggi untuk bekerja atau sekadar camilan manis untuk bersantai? Amori ingin mengenalmu lebih dekat', + ), + OnboardingData( + image: 'assets/images/onboard3.png', + title: 'Rekomendasi Cerdas', + description: + 'Saran Terbaik dari Gemini AI. Dapatkan rekomendasi menu yang paling cocok dengan preferensi pribadimu saat ini', + ), + ]; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onSkip() async { + // Mark onboarding as completed + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('is_first_launch', false); + + if (!mounted) return; + Navigator.of(context).pushReplacement( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + const LoginScreen(), + transitionDuration: const Duration(milliseconds: 400), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation.drive( + Tween( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeOut)), + ), + child: child, + ); + }, + ), + ); + } + + void _onNext() { + if (_currentPage < _pages.length - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + _onSkip(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // PageView + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentPage = index; + }); + }, + itemCount: _pages.length, + itemBuilder: (context, index) { + return OnboardingPage(data: _pages[index]); + }, + ), + ), + + // Bottom controls: Skip, Page Indicator, Arrow Button + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Skip button (left) + TextButton( + onPressed: _onSkip, + style: TextButton.styleFrom( + minimumSize: const Size(60, 40), + padding: EdgeInsets.zero, + ), + child: Text( + 'Skip', + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + color: const Color(0xFF4A3C3C).withOpacity(0.6), + fontWeight: FontWeight.w400, + ), + ), + ), + + // Page Indicator (center) + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + _pages.length, + (index) => AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: _currentPage == index ? 32 : 8, + height: 8, + decoration: BoxDecoration( + color: _currentPage == index + ? const Color(0xFF5A4A3A) + : Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + + // Arrow button (right) with shadow overlay - CIRCULAR + Stack( + clipBehavior: Clip.none, + children: [ + // Shadow overlay (offset to top) + Positioned( + top: -4, + left: 4, + right: -4, + bottom: 4, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: const Color(0xFF5A4A3A).withOpacity(0.3), + shape: BoxShape.circle, + ), + ), + ), + // Main button - CIRCULAR with touch animation + Material( + color: const Color(0xFF5A4A3A), + shape: const CircleBorder(), + child: InkWell( + onTap: _onNext, + customBorder: const CircleBorder(), + splashColor: Colors.white.withOpacity(0.3), + highlightColor: Colors.white.withOpacity(0.1), + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + shape: BoxShape.circle, + ), + child: const Icon( + Icons.arrow_forward, + color: Colors.white, + size: 24, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class OnboardingPage extends StatelessWidget { + final OnboardingData data; + + const OnboardingPage({super.key, required this.data}); + + @override + Widget build(BuildContext context) { + // Define the color with 85% opacity + final textColor = const Color(0xFF4A3C3C).withOpacity(0.85); + + return Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Image + Expanded( + flex: 3, + child: Image.asset(data.image, fit: BoxFit.contain), + ), + + const SizedBox(height: 48), + + // Title + Text( + data.title, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 24, + fontWeight: FontWeight.w600, // SemiBold + color: textColor, + height: 1.3, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 16), + + // Description + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + data.description, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + fontWeight: FontWeight.w400, // Regular + color: textColor.withOpacity(0.8), + height: 1.6, + ), + textAlign: TextAlign.center, + ), + ), + + const SizedBox(height: 48), + ], + ), + ); + } +} + +class OnboardingData { + final String image; + final String title; + final String description; + + OnboardingData({ + required this.image, + required this.title, + required this.description, + }); +} diff --git a/lib/screens/profile/notification_settings_screen.dart b/lib/screens/profile/notification_settings_screen.dart new file mode 100644 index 0000000..ed3a176 --- /dev/null +++ b/lib/screens/profile/notification_settings_screen.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/constants/app_constants.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; + +class NotificationSettingsScreen extends StatefulWidget { + const NotificationSettingsScreen({super.key}); + + @override + State createState() => + _NotificationSettingsScreenState(); +} + +class _NotificationSettingsScreenState + extends State { + static const _keyMain = 'notif_main'; + static const _keyRekomendasi = 'notif_rekomendasi'; + static const _keyCheckin = 'notif_checkin'; + static const _keyPromo = 'notif_promo'; + + bool _mainEnabled = true; + bool _rekomendasiEnabled = true; + bool _checkinEnabled = true; + bool _promoEnabled = false; + bool _isLoading = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _mainEnabled = prefs.getBool(_keyMain) ?? true; + _rekomendasiEnabled = prefs.getBool(_keyRekomendasi) ?? true; + _checkinEnabled = prefs.getBool(_keyCheckin) ?? true; + _promoEnabled = prefs.getBool(_keyPromo) ?? false; + _isLoading = false; + }); + } + + Future _setMain(bool value) async { + setState(() => _isSaving = true); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyMain, value); + + final firebaseService = FirebaseService(); + final uid = firebaseService.currentUser?.uid; + if (uid != null) { + final userData = await firebaseService.getUserData(uid); + if (userData != null) { + await firebaseService.updateUserData( + userData.copyWith( + preferences: userData.preferences.copyWith( + enableNotifications: value, + ), + ), + ); + } + } + if (value) await firebaseService.initializeNotifications(); + + setState(() => _mainEnabled = value); + } catch (e) { + DialogService().showError('Gagal menyimpan: $e'); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + Future _toggle( + String key, + bool value, + void Function(bool) setter, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(key, value); + setState(() => setter(value)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: const Text( + 'Pengaturan Notifikasi', + style: TextStyle( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: AppTheme.warmWhite, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.primaryBrown), + ), + body: _isLoading + ? const Center( + child: CircularProgressIndicator(color: AppTheme.primaryBrown), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Notifikasi utama + _buildSwitchCard( + icon: Icons.notifications_outlined, + title: 'Aktifkan Notifikasi', + subtitle: + 'Izinkan MuningAI mengirim notifikasi ke perangkatmu', + value: _mainEnabled, + onChanged: _isSaving ? null : _setMain, + ), + + const SizedBox(height: 8), + + // Jenis notifikasi + _buildSwitchCard( + icon: Icons.restaurant_menu_outlined, + title: 'Rekomendasi Menu', + subtitle: + 'Notifikasi saat ada rekomendasi menu baru untukmu', + value: _mainEnabled && _rekomendasiEnabled, + onChanged: _mainEnabled + ? (v) => _toggle( + _keyRekomendasi, + v, + (val) => _rekomendasiEnabled = val, + ) + : null, + ), + + const SizedBox(height: 8), + + _buildSwitchCard( + icon: Icons.today_outlined, + title: 'Reminder Check-in', + subtitle: 'Pengingat harian untuk mengisi check-in mood', + value: _mainEnabled && _checkinEnabled, + onChanged: _mainEnabled + ? (v) => _toggle( + _keyCheckin, + v, + (val) => _checkinEnabled = val, + ) + : null, + ), + + const SizedBox(height: 8), + + _buildSwitchCard( + icon: Icons.local_offer_outlined, + title: 'Promo & Penawaran', + subtitle: 'Info promo dan menu spesial dari kafe', + value: _mainEnabled && _promoEnabled, + onChanged: _mainEnabled + ? (v) => _toggle( + _keyPromo, + v, + (val) => _promoEnabled = val, + ) + : null, + ), + + const SizedBox(height: 16), + + Center( + child: Text( + 'Kamu juga dapat mengatur izin notifikasi\nmelalui Pengaturan perangkatmu.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: AppTheme.grey500), + ), + ), + ], + ), + ), + ); + } + + Widget _buildSwitchCard({ + required IconData icon, + required String title, + required String subtitle, + required bool value, + required ValueChanged? onChanged, + }) { + final active = onChanged != null; + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + secondary: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(active ? 0.1 : 0.04), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + color: active ? AppTheme.primaryBrown : AppTheme.grey300, + size: 20, + ), + ), + title: Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: active ? const Color(0xFF424242) : AppTheme.grey400, + ), + ), + subtitle: Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: active ? AppTheme.grey600 : AppTheme.grey300, + ), + ), + value: value, + onChanged: onChanged, + activeColor: AppTheme.primaryBrown, + ), + ); + } +} diff --git a/lib/screens/profile/preferences_screen.dart b/lib/screens/profile/preferences_screen.dart new file mode 100644 index 0000000..30b79c7 --- /dev/null +++ b/lib/screens/profile/preferences_screen.dart @@ -0,0 +1,591 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/constants/app_constants.dart'; +import '../../models/user_model.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; + +class PreferencesScreen extends ConsumerStatefulWidget { + final UserPreferences userPreferences; + final Function(UserPreferences) onPreferencesUpdated; + + const PreferencesScreen({ + super.key, + required this.userPreferences, + required this.onPreferencesUpdated, + }); + + @override + ConsumerState createState() => _PreferencesScreenState(); +} + +class _PreferencesScreenState extends ConsumerState { + late UserPreferences _preferences; + bool _isLoading = false; + + // Available options + final List _availableCategories = [ + 'Makanan Berat', + 'Makanan Ringan', + 'Minuman Panas', + 'Minuman Dingin', + 'Dessert & Manis', + 'Sarapan', + 'Vegetarian', + 'Berkuah', + 'Gurih & Asin', + 'Pedas', + ]; + + final List _availableAllergies = [ + 'Gluten', + 'Kacang', + 'Susu', + 'Telur', + 'Seafood', + 'Kedelai', + 'Wijen', + 'Sulfit', + ]; + + final List _availableMoods = [ + 'Energetic', + 'Relaxed', + 'Happy', + 'Focused', + 'Comfort', + 'Adventure', + ]; + + final List _availableSpiceLevels = [ + 'Mild', + 'Medium', + 'Hot', + 'Very Hot', + ]; + + final List _availableDietaryRestrictions = [ + 'None', + 'Vegetarian', + 'Vegan', + 'Halal', + 'Keto', + 'Low Carb', + ]; + + @override + void initState() { + super.initState(); + _preferences = widget.userPreferences; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: const Text( + 'Preferensi', + style: TextStyle( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: AppTheme.warmWhite, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.primaryBrown), + actions: [ + TextButton( + onPressed: _isLoading ? null : _savePreferences, + style: TextButton.styleFrom( + foregroundColor: AppTheme.primaryBrown, + textStyle: const TextStyle(fontWeight: FontWeight.bold), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppTheme.primaryBrown, + ), + ), + ) + : const Text('Simpan'), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + _buildHeader(), + + const SizedBox(height: 24), + + // Favorite Categories + _buildFavoriteCategoriesSection(), + + const SizedBox(height: 24), + + // Allergies + _buildAllergiesSection(), + + const SizedBox(height: 24), + + // Mood + _buildMoodSection(), + + const SizedBox(height: 24), + + // Spice Level + _buildSpiceLevelSection(), + + const SizedBox(height: 24), + + // Dietary Restrictions + _buildDietaryRestrictionsSection(), + + const SizedBox(height: 24), + + // Budget + _buildBudgetSection(), + + const SizedBox(height: 24), + + // Notifications + _buildNotificationsSection(), + + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + border: Border.all(color: AppTheme.primaryBrown.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.tune, color: AppTheme.primaryBrown, size: 24), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Personalisasi Rekomendasi', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Atur preferensi Anda untuk mendapatkan rekomendasi menu yang lebih personal dan akurat', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.grey700), + ), + ], + ), + ); + } + + Widget _buildFavoriteCategoriesSection() { + return _buildSection( + title: 'Kategori Favorit', + subtitle: 'Pilih jenis makanan/minuman yang Anda sukai', + icon: Icons.favorite_outline, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _availableCategories.map((category) { + final isSelected = _preferences.favoriteCategories.contains(category); + return FilterChip( + label: Text(category), + selected: isSelected, + onSelected: (selected) { + setState(() { + if (selected) { + _preferences = _preferences.copyWith( + favoriteCategories: [ + ..._preferences.favoriteCategories, + category, + ], + ); + } else { + _preferences = _preferences.copyWith( + favoriteCategories: _preferences.favoriteCategories + .where((c) => c != category) + .toList(), + ); + } + }); + }, + selectedColor: AppTheme.primaryBrown.withOpacity(0.2), + checkmarkColor: AppTheme.primaryBrown, + ); + }).toList(), + ), + ); + } + + Widget _buildAllergiesSection() { + return _buildSection( + title: 'Alergi & Pantangan', + subtitle: 'Pilih bahan yang harus dihindari', + icon: Icons.warning_outlined, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _availableAllergies.map((allergy) { + final isSelected = _preferences.allergies.contains(allergy); + return FilterChip( + label: Text(allergy), + selected: isSelected, + onSelected: (selected) { + setState(() { + if (selected) { + _preferences = _preferences.copyWith( + allergies: [..._preferences.allergies, allergy], + ); + } else { + _preferences = _preferences.copyWith( + allergies: _preferences.allergies + .where((a) => a != allergy) + .toList(), + ); + } + }); + }, + selectedColor: AppTheme.error.withOpacity(0.2), + checkmarkColor: AppTheme.error, + ); + }).toList(), + ), + ); + } + + Widget _buildMoodSection() { + return _buildSection( + title: 'Mood Saat Ini', + subtitle: 'Bagaimana perasaan Anda hari ini?', + icon: Icons.mood_outlined, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _availableMoods.map((mood) { + final isSelected = _preferences.mood == mood.toLowerCase(); + return ChoiceChip( + label: Text(mood), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _preferences = _preferences.copyWith( + mood: mood.toLowerCase(), + ); + }); + } + }, + selectedColor: AppTheme.accent.withOpacity(0.2), + ); + }).toList(), + ), + ); + } + + Widget _buildSpiceLevelSection() { + return _buildSection( + title: 'Level Pedas', + subtitle: 'Seberapa pedas yang Anda suka?', + icon: Icons.local_fire_department_outlined, + child: Column( + children: _availableSpiceLevels.asMap().entries.map((entry) { + final index = entry.key; + final level = entry.value; + final isSelected = _preferences.spiceLevel == level.toLowerCase(); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: isSelected + ? AppTheme.primaryBrown.withOpacity(0.1) + : AppTheme.grey50, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? AppTheme.primaryBrown : AppTheme.grey200, + width: isSelected ? 2 : 1, + ), + ), + child: RadioListTile( + title: Row( + children: [ + // Fire icons to represent spice level + ...List.generate( + index + 1, + (i) => Icon( + Icons.local_fire_department, + size: 16, + color: isSelected + ? AppTheme.primaryBrown + : AppTheme.error, + ), + ), + const SizedBox(width: 8), + Text( + level, + style: TextStyle( + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? AppTheme.primaryBrown + : AppTheme.grey800, + ), + ), + ], + ), + subtitle: Text( + _getSpiceLevelDescription(level), + style: TextStyle( + color: isSelected + ? AppTheme.primaryBrown.withOpacity(0.8) + : AppTheme.grey600, + fontSize: 12, + ), + ), + value: level.toLowerCase(), + groupValue: _preferences.spiceLevel, + onChanged: (value) { + if (value != null) { + setState(() { + _preferences = _preferences.copyWith(spiceLevel: value); + }); + } + }, + activeColor: AppTheme.primaryBrown, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildDietaryRestrictionsSection() { + return _buildSection( + title: 'Pembatasan Diet', + subtitle: 'Pilih jenis diet yang Anda jalani', + icon: Icons.restaurant_outlined, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _availableDietaryRestrictions.map((restriction) { + final isSelected = + _preferences.dietaryRestriction == restriction.toLowerCase(); + return ChoiceChip( + label: Text(restriction), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() { + _preferences = _preferences.copyWith( + dietaryRestriction: restriction.toLowerCase(), + ); + }); + } + }, + selectedColor: AppTheme.success.withOpacity(0.2), + ); + }).toList(), + ), + ); + } + + Widget _buildBudgetSection() { + return _buildSection( + title: 'Budget Maksimal', + subtitle: 'Rp ${_preferences.budgetRange.toString()}', + icon: Icons.account_balance_wallet_outlined, + child: Column( + children: [ + Slider( + value: _preferences.budgetRange.toDouble(), + min: 10000, + max: 200000, + divisions: 38, + label: 'Rp ${_preferences.budgetRange.toString()}', + onChanged: (value) { + setState(() { + _preferences = _preferences.copyWith( + budgetRange: value.toInt(), + ); + }); + }, + activeColor: AppTheme.primaryBrown, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Rp 10K', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.grey600), + ), + Text( + 'Rp 200K', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.grey600), + ), + ], + ), + ], + ), + ); + } + + Widget _buildNotificationsSection() { + return _buildSection( + title: 'Notifikasi', + subtitle: 'Pengaturan notifikasi aplikasi', + icon: Icons.notifications_outlined, + child: SwitchListTile( + title: const Text('Aktifkan Notifikasi'), + subtitle: const Text('Terima notifikasi untuk promo dan menu baru'), + value: _preferences.enableNotifications, + onChanged: (value) { + setState(() { + _preferences = _preferences.copyWith(enableNotifications: value); + }); + }, + activeColor: AppTheme.primaryBrown, + contentPadding: EdgeInsets.zero, + ), + ); + } + + Widget _buildSection({ + required String title, + required String subtitle, + required IconData icon, + required Widget child, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: AppTheme.primaryBrown, size: 20), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + ), + ), + Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.grey600), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } + + String _getSpiceLevelDescription(String level) { + switch (level.toLowerCase()) { + case 'mild': + return '๐ŸŒฑ Tidak pedas atau sedikit pedas - cocok untuk semua'; + case 'medium': + return '๐ŸŒถ๏ธ Pedas sedang - masih nyaman dan flavorful'; + case 'hot': + return '๐Ÿ”ฅ Pedas tinggi - untuk pecinta sensasi pedas'; + case 'very hot': + return '๐ŸŒ‹ Sangat pedas - tantangan untuk yang berani!'; + default: + return ''; + } + } + + Future _savePreferences() async { + setState(() => _isLoading = true); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) { + throw Exception('Pengguna tidak terautentikasi'); + } + + await firebaseService.updateUserPreferences( + currentUser.uid, + _preferences, + ); + + widget.onPreferencesUpdated(_preferences); + + if (mounted) { + DialogService().showSuccess('Preferensi berhasil disimpan'); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + DialogService().showError('Gagal menyimpan preferensi: $e'); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } +} diff --git a/lib/screens/profile/privacy_security_screen.dart b/lib/screens/profile/privacy_security_screen.dart new file mode 100644 index 0000000..5493135 --- /dev/null +++ b/lib/screens/profile/privacy_security_screen.dart @@ -0,0 +1,529 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/constants/app_constants.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; +import '../auth/login_screen.dart'; + +class PrivacySecurityScreen extends StatefulWidget { + const PrivacySecurityScreen({super.key}); + + @override + State createState() => _PrivacySecurityScreenState(); +} + +class _PrivacySecurityScreenState extends State { + bool _isGoogleUser = false; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _checkAuthProvider(); + } + + void _checkAuthProvider() { + final user = FirebaseService().currentUser; + if (user != null) { + _isGoogleUser = user.providerData.any( + (p) => p.providerId == 'google.com', + ); + } + setState(() => _isLoading = false); + } + + // โ”€โ”€ Ganti Password โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + void _showChangePassword() { + if (_isGoogleUser) { + DialogService().showInfo( + 'Akun Google tidak menggunakan password. Kelola kata sandi melalui akun Google-mu.', + ); + return; + } + final currentCtrl = TextEditingController(); + final newCtrl = TextEditingController(); + final confirmCtrl = TextEditingController(); + bool oscCurrent = true, oscNew = true, oscConfirm = true; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setS) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + 'Ganti Password', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF424242), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _pwField( + ctrl: currentCtrl, + label: 'Password Saat Ini', + obs: oscCurrent, + onToggle: () => setS(() => oscCurrent = !oscCurrent), + ), + const SizedBox(height: 12), + _pwField( + ctrl: newCtrl, + label: 'Password Baru', + obs: oscNew, + onToggle: () => setS(() => oscNew = !oscNew), + ), + const SizedBox(height: 12), + _pwField( + ctrl: confirmCtrl, + label: 'Konfirmasi Password Baru', + obs: oscConfirm, + onToggle: () => setS(() => oscConfirm = !oscConfirm), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: AppTheme.grey600), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + if (newCtrl.text != confirmCtrl.text) { + DialogService().showError('Password baru tidak cocok'); + return; + } + if (newCtrl.text.length < 6) { + DialogService().showError('Password minimal 6 karakter'); + return; + } + Navigator.pop(ctx); + try { + await FirebaseService().updatePassword(newCtrl.text); + DialogService().showSuccess('Password berhasil diubah'); + } catch (e) { + DialogService().showError('Gagal: $e'); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBrown, + ), + child: const Text('Simpan'), + ), + ], + ), + ), + ); + } + + Widget _pwField({ + required TextEditingController ctrl, + required String label, + required bool obs, + required VoidCallback onToggle, + }) { + return TextField( + controller: ctrl, + obscureText: obs, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + suffixIcon: IconButton( + icon: Icon( + obs ? Icons.visibility_off : Icons.visibility, + color: AppTheme.grey600, + ), + onPressed: onToggle, + ), + ), + ); + } + + // โ”€โ”€ Data Saya โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + void _showDataInfo() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Data Saya', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF424242), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Data yang kami simpan untuk memberikan rekomendasi personal:', + style: TextStyle(fontSize: 13, color: AppTheme.grey700), + ), + const SizedBox(height: 16), + _dataRow( + Icons.person_outline, + 'Profil', + 'Nama, email, gender, usia, alamat', + ), + _dataRow( + Icons.tune, + 'Preferensi', + 'Kategori favorit, alergi, level pedas, budget', + ), + _dataRow( + Icons.mood_outlined, + 'Check-in Harian', + 'Mood, cuaca, suasana (hanya hari ini)', + ), + _dataRow( + Icons.history, + 'Riwayat', + 'Riwayat rekomendasi menu yang pernah diterima', + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppTheme.grey50, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.shield_outlined, + size: 16, + color: AppTheme.grey600, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Data kamu tidak dibagikan ke pihak ketiga manapun.', + style: TextStyle(fontSize: 11, color: AppTheme.grey600), + ), + ), + ], + ), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBrown, + ), + child: const Text('Mengerti'), + ), + ], + ), + ); + } + + Widget _dataRow(IconData icon, String title, String desc) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: AppTheme.primaryBrown), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Color(0xFF424242), + ), + ), + Text( + desc, + style: TextStyle(fontSize: 11, color: AppTheme.grey600), + ), + ], + ), + ), + ], + ), + ); + } + + // โ”€โ”€ Hapus Riwayat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + void _showDeleteHistory() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Hapus Riwayat', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF424242), + ), + ), + content: const Text( + 'Semua riwayat rekomendasi menu akan dihapus permanen dan tidak dapat dipulihkan. Lanjutkan?', + style: TextStyle(color: Color(0xFF616161)), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: AppTheme.grey600), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + try { + final firebaseService = FirebaseService(); + final uid = firebaseService.currentUser?.uid; + if (uid == null) return; + final recs = await firebaseService.getUserRecommendations( + uid, + limit: 999, + ); + for (final rec in recs) { + await firebaseService.deleteRecommendation(rec.id); + } + DialogService().showSuccess('Riwayat berhasil dihapus'); + } catch (e) { + DialogService().showError('Gagal menghapus riwayat: $e'); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.error), + child: const Text('Hapus'), + ), + ], + ), + ); + } + + // โ”€โ”€ Hapus Akun โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + void _showDeleteAccount() { + final confirmCtrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Text( + 'Hapus Akun', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF424242), + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Tindakan ini akan menghapus akun dan semua data kamu secara permanen. Tidak dapat dibatalkan.', + style: TextStyle(fontSize: 13, color: AppTheme.grey700), + ), + const SizedBox(height: 16), + Text( + 'Ketik "HAPUS" untuk konfirmasi:', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: AppTheme.grey800, + ), + ), + const SizedBox(height: 8), + TextField( + controller: confirmCtrl, + decoration: InputDecoration( + hintText: 'HAPUS', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + style: TextButton.styleFrom(foregroundColor: AppTheme.grey600), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + if (confirmCtrl.text.trim() != 'HAPUS') { + DialogService().showError('Konfirmasi tidak sesuai'); + return; + } + Navigator.pop(ctx); + try { + final svc = FirebaseService(); + final uid = svc.currentUser?.uid; + if (uid != null) { + final recs = await svc.getUserRecommendations( + uid, + limit: 999, + ); + for (final rec in recs) + await svc.deleteRecommendation(rec.id); + } + await svc.signOut(); + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (_) => false, + ); + } + } catch (e) { + DialogService().showError('Gagal menghapus akun: $e'); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: AppTheme.error), + child: const Text('Hapus Akun'), + ), + ], + ), + ); + } + + // โ”€โ”€ Build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: const Text( + 'Privasi & Keamanan', + style: TextStyle( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: AppTheme.warmWhite, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.primaryBrown), + ), + body: _isLoading + ? const Center( + child: CircularProgressIndicator(color: AppTheme.primaryBrown), + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Keamanan + _buildItem( + icon: Icons.lock_outline, + title: 'Ganti Password', + subtitle: _isGoogleUser + ? 'Dikelola via akun Google' + : 'Ubah kata sandi akunmu', + onTap: _showChangePassword, + faded: _isGoogleUser, + ), + const SizedBox(height: 8), + + // Data & Privasi + _buildItem( + icon: Icons.info_outline, + title: 'Data Saya', + subtitle: 'Lihat data apa yang kami simpan tentang kamu', + onTap: _showDataInfo, + ), + const SizedBox(height: 8), + _buildItem( + icon: Icons.history, + title: 'Hapus Riwayat Rekomendasi', + subtitle: + 'Hapus semua riwayat menu yang pernah direkomendasikan', + onTap: _showDeleteHistory, + iconColor: AppTheme.warning, + ), + const SizedBox(height: 8), + + // Hapus Akun + _buildItem( + icon: Icons.delete_forever_outlined, + title: 'Hapus Akun', + subtitle: 'Hapus akun dan semua data secara permanen', + onTap: _showDeleteAccount, + iconColor: AppTheme.error, + textColor: AppTheme.error, + ), + ], + ), + ), + ); + } + + /// Sama persis dengan _buildMenuOption di profile_screen.dart + Widget _buildItem({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + Color? iconColor, + Color? textColor, + bool faded = false, + }) { + final iColor = faded + ? AppTheme.grey300 + : (iconColor ?? AppTheme.primaryBrown); + return Container( + margin: EdgeInsets.zero, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: iColor, size: 20), + ), + title: Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: faded + ? AppTheme.grey400 + : (textColor ?? const Color(0xFF424242)), + ), + ), + subtitle: Text( + subtitle, + style: TextStyle( + fontSize: 12, + color: faded ? AppTheme.grey300 : AppTheme.grey600, + ), + ), + trailing: Icon( + Icons.chevron_right, + color: faded ? AppTheme.grey200 : AppTheme.grey400, + ), + onTap: faded ? null : onTap, + ), + ); + } +} diff --git a/lib/screens/profile/profile_screen.dart b/lib/screens/profile/profile_screen.dart new file mode 100644 index 0000000..9d789ce --- /dev/null +++ b/lib/screens/profile/profile_screen.dart @@ -0,0 +1,761 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:firebase_core/firebase_core.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/config/app_config.dart'; +import '../../core/constants/app_constants.dart'; +import '../../models/user_model.dart'; +import '../../services/firebase_service.dart'; +import '../../services/dialog_service.dart'; +import '../auth/login_screen.dart'; +import 'preferences_screen.dart'; +import 'notification_settings_screen.dart'; +import 'privacy_security_screen.dart'; +import '../onboarding/daily_checkin_screen.dart'; + +class ProfileScreen extends ConsumerStatefulWidget { + const ProfileScreen({super.key}); + + @override + ConsumerState createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + UserModel? _userData; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadUserData(); + } + + Future _loadUserData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final firebaseService = FirebaseService(); + final currentUser = firebaseService.currentUser; + + if (currentUser == null) { + throw Exception('Pengguna tidak terautentikasi'); + } + + final userData = await firebaseService.getUserData(currentUser.uid); + + // ๐Ÿ” DEBUG โ€” hapus setelah debugging selesai + debugPrint('=== PROFILE DEBUG ==='); + debugPrint('userData: ${userData != null ? "OK" : "NULL"}'); + debugPrint( + 'favoriteCategories: ${userData?.preferences.favoriteCategories}', + ); + debugPrint('allergies: ${userData?.preferences.allergies}'); + debugPrint('spiceLevel: ${userData?.preferences.spiceLevel}'); + debugPrint('budgetRange: ${userData?.preferences.budgetRange}'); + debugPrint('dailyPreferences: ${userData?.dailyPreferences}'); + debugPrint('===================='); + + setState(() { + _userData = userData; + _isLoading = false; + }); + } catch (e) { + debugPrint('Profile load error: $e'); + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: const Text( + 'Profil', + style: TextStyle( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: AppTheme.warmWhite, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.primaryBrown), + ), + body: _isLoading + ? _buildLoadingState() + : _error != null + ? _buildErrorState() + : _buildProfileContent(), + ); + } + + Widget _buildLoadingState() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Memuat profil...'), + ], + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: AppTheme.error), + const SizedBox(height: 16), + Text( + 'Gagal memuat profil', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + _error ?? 'Terjadi kesalahan', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.grey600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _loadUserData, + child: const Text('Coba Lagi'), + ), + ], + ), + ); + } + + Widget _buildProfileContent() { + final user = FirebaseService().currentUser; + final displayName = + _userData?.displayName ?? user?.displayName ?? 'Pengguna'; + final email = _userData?.email ?? user?.email ?? ''; + + // Extra bottom padding agar konten tidak tertutup navbar (extendBody: true) + final bottomPadding = + MediaQuery.of(context).padding.bottom + + kBottomNavigationBarHeight + + 16.0; + + return SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + AppConstants.defaultPadding, + AppConstants.defaultPadding, + AppConstants.defaultPadding, + bottomPadding, + ), + child: Column( + children: [ + // Profile Header + _buildProfileHeader(displayName, email, user?.photoURL), + + const SizedBox(height: 32), + + // Preferences Summary + _buildPreferencesCard(), + + const SizedBox(height: 16), + + // Menu Options + _buildMenuOptions(), + + const SizedBox(height: 32), + + // Sign Out Button + _buildSignOutButton(), + ], + ), + ); + } + + Widget _buildProfileHeader( + String displayName, + String email, + String? photoUrl, + ) { + final firstName = displayName.split(' ').first; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppTheme.primaryBrown, AppTheme.coffeeBrown], + ), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: AppTheme.primaryBrown.withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + children: [ + // Profile Picture + CircleAvatar( + radius: 50, + backgroundColor: Colors.white.withOpacity(0.2), + backgroundImage: photoUrl != null + ? NetworkImage(photoUrl) + : null, + child: photoUrl == null + ? Text( + firstName.isNotEmpty + ? firstName[0].toUpperCase() + : 'U', + style: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.bold, + ), + ) + : null, + ), + + const SizedBox(height: 16), + + // Name + Text( + displayName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 4), + + // Email + Text( + email, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.9), + ), + ), + + const SizedBox(height: 16), + + // Member Since + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Member sejak ${_userData?.createdAt.year ?? DateTime.now().year}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + Widget _buildPreferencesCard() { + final preferences = _userData?.preferences ?? UserPreferences(); + final daily = _userData?.dailyPreferences; + final lastCheckin = _userData?.lastCheckinDate; + + // Kategori Favorit + final kategori = preferences.favoriteCategories.isNotEmpty + ? preferences.favoriteCategories.join(', ') + : 'Belum diatur'; + + // Alergi + final alergi = preferences.allergies.isNotEmpty + ? preferences.allergies.join(', ') + : 'Tidak ada'; + + // Mood: dari daily check-in, fallback preferences + String moodText = 'Belum check-in hari ini'; + if (daily != null && daily['mood'] != null) { + moodText = daily['mood'].toString(); + } else if (preferences.mood.isNotEmpty && preferences.mood != 'neutral') { + moodText = preferences.mood; + } + + // Level Pedas + final levelPedas = _formatSpiceLevel(preferences.spiceLevel); + + // Budget: dari daily check-in lalu fallback ke preferences + String budgetText = 'Belum diatur'; + if (daily != null && daily['budget'] != null) { + final b = daily['budget']; + budgetText = 'Rp ${_formatBudget(b is int ? b : (b as num).toInt())}'; + } else if (preferences.budgetRange > 0) { + budgetText = 'Rp ${_formatBudget(preferences.budgetRange)}'; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + children: [ + Icon(Icons.tune, color: AppTheme.primaryBrown, size: 20), + const SizedBox(width: 8), + Text( + 'Preferensi Saya', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + ), + ), + const Spacer(), + TextButton( + onPressed: () => _navigateToPreferences(), + child: const Text('Edit'), + ), + ], + ), + + // Sync label + if (lastCheckin != null) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Icon(Icons.sync, size: 12, color: AppTheme.grey500), + const SizedBox(width: 4), + Text( + _formatCheckinLabel(lastCheckin), + style: TextStyle( + fontSize: 11, + color: AppTheme.grey500, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + + _buildPreferenceItem( + 'Kategori Favorit', + kategori, + Icons.favorite_outline, + ), + _buildPreferenceItem('Alergi', alergi, Icons.warning_outlined), + _buildPreferenceItem( + 'Mood Saat Ini', + moodText, + Icons.mood_outlined, + valueColor: AppTheme.primaryBrown, + ), + _buildPreferenceItem( + 'Level Pedas', + levelPedas, + Icons.local_fire_department_outlined, + ), + _buildPreferenceItem( + 'Budget', + budgetText, + Icons.account_balance_wallet_outlined, + ), + ], + ), + ); + } + + /// Label tanggal check-in yang mudah dibaca + String _formatCheckinLabel(DateTime dt) { + final now = DateTime.now(); + final isToday = + dt.year == now.year && dt.month == now.month && dt.day == now.day; + if (isToday) return 'Mood diperbarui hari ini'; + const months = [ + '', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'Mei', + 'Jun', + 'Jul', + 'Agu', + 'Sep', + 'Okt', + 'Nov', + 'Des', + ]; + return 'Terakhir diperbarui ${dt.day} ${months[dt.month]}'; + } + + /// Format angka ke format Rp (misal 30000 โ†’ "30.000") + String _formatBudget(int amount) { + final s = amount.toString(); + final buffer = StringBuffer(); + int count = 0; + for (int i = s.length - 1; i >= 0; i--) { + if (count > 0 && count % 3 == 0) buffer.write('.'); + buffer.write(s[i]); + count++; + } + return buffer.toString().split('').reversed.join(); + } + + Widget _buildPreferenceItem( + String title, + String value, + IconData icon, { + Color? valueColor, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, size: 16, color: AppTheme.grey600), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF757575), // grey600 + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: valueColor ?? const Color(0xFF424242), // grey800 + ), + ), + ], + ), + ), + ], + ), + ); + } + + String _formatSpiceLevel(String level) { + switch (level.toLowerCase()) { + case 'mild': + return '๐ŸŒฑ Tidak Pedas'; + case 'medium': + return '๐ŸŒถ๏ธ Sedang'; + case 'hot': + return '๐Ÿ”ฅ Pedas'; + case 'very hot': + return '๐ŸŒ‹ Sangat Pedas'; + default: + return 'Belum diatur'; + } + } + + Widget _buildMenuOptions() { + return Column( + children: [ + _buildMenuOption( + icon: Icons.edit_calendar_outlined, + title: 'Isi Ulang Check-in Harian', + subtitle: 'Perbarui mood, keinginan, & kondisi hari ini', + onTap: _navigateToCheckin, + highlight: true, + ), + _buildMenuOption( + icon: Icons.notifications_outlined, + title: 'Notifikasi', + subtitle: 'Pengaturan notifikasi aplikasi', + onTap: _showNotificationSettings, + ), + _buildMenuOption( + icon: Icons.privacy_tip_outlined, + title: 'Privasi & Keamanan', + subtitle: 'Pengaturan privasi dan keamanan data', + onTap: _showPrivacySettings, + ), + _buildMenuOption( + icon: Icons.help_outline, + title: 'Bantuan & FAQ', + subtitle: 'Pertanyaan yang sering diajukan', + onTap: _showHelp, + ), + _buildMenuOption( + icon: Icons.info_outline, + title: 'Tentang Aplikasi', + subtitle: 'Informasi aplikasi dan versi', + onTap: _showAbout, + ), + ], + ); + } + + Widget _buildMenuOption({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + bool highlight = false, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: highlight + ? AppTheme.primaryBrown.withOpacity(0.06) + : Colors.white, + border: highlight + ? Border.all(color: AppTheme.primaryBrown.withOpacity(0.3)) + : null, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(highlight ? 0.2 : 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppTheme.primaryBrown, size: 20), + ), + title: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: highlight ? AppTheme.primaryBrown : null, + ), + ), + subtitle: Text( + subtitle, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.grey600), + ), + trailing: const Icon(Icons.chevron_right, color: AppTheme.grey400), + onTap: onTap, + ), + ); + } + + Future _navigateToCheckin() async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DailyCheckinScreen(isMandatory: false), + ), + ); + // Refresh halaman profil setelah check-in selesai agar data terbaru tampil + if (result == true && mounted) { + _loadUserData(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text( + 'โœ… Check-in berhasil! Preferensi profil diperbarui.', + style: TextStyle(fontFamily: 'Poppins'), + ), + backgroundColor: AppTheme.primaryBrown, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + } + } + + Widget _buildSignOutButton() { + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _signOut, + icon: const Icon(Icons.logout, color: AppTheme.error), + label: const Text('Keluar', style: TextStyle(color: AppTheme.error)), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: AppTheme.error), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ); + } + + void _navigateToPreferences() async { + // Sinkronkan data preferensi dengan daily check-in (jika ada override) + final currentPrefs = _userData?.preferences ?? UserPreferences(); + if (_userData?.dailyPreferences?['budget'] != null) { + final overrideBudget = (_userData!.dailyPreferences!['budget'] as num).toInt(); + // Hanya ubah untuk UI di PreferencesScreen agar sama persis + currentPrefs.budgetRange = overrideBudget; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PreferencesScreen( + userPreferences: currentPrefs, + onPreferencesUpdated: (preferences) { + // Update state langsung dari callback (optimistic update) + setState(() { + if (_userData != null) { + _userData = _userData!.copyWith(preferences: preferences); + // Juga sinkronkan state daily override di UI profil + if (_userData!.dailyPreferences != null) { + _userData!.dailyPreferences!['budget'] = preferences.budgetRange; + } + } + }); + }, + ), + ), + ); + // Setelah kembali dari halaman preferensi, selalu reload dari Firebase + // untuk memastikan data terbaru tersinkron + _loadUserData(); + } + + + void _showNotificationSettings() { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NotificationSettingsScreen()), + ); + } + + void _showPrivacySettings() { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const PrivacySecurityScreen())); + } + + void _showHelp() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Bantuan & FAQ'), + content: const SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Cara menggunakan aplikasi:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text('1. Ambil foto menu kafe'), + Text('2. Tunggu AI menganalisis menu'), + Text('3. Dapatkan rekomendasi personal'), + Text('4. Chat dengan AI untuk pertanyaan lanjutan'), + SizedBox(height: 16), + Text( + 'Tips untuk hasil terbaik:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text('โ€ข Pastikan foto jelas dan tidak buram'), + Text('โ€ข Gunakan pencahayaan yang cukup'), + Text('โ€ข Atur preferensi dengan lengkap'), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Tutup'), + ), + ], + ), + ); + } + + void _showAbout() { + showAboutDialog( + context: context, + applicationName: AppConstants.appName, + applicationVersion: AppConstants.appVersion, + applicationIcon: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppTheme.primaryBrown, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.local_cafe, color: Colors.white, size: 32), + ), + children: [ + const Text('Aplikasi rekomendasi menu kafe menggunakan AI dan OCR'), + const SizedBox(height: 16), + Text('ยฉ 2024 ${AppConstants.kafeName}'), + ], + ); + } + + Future _signOut() async { + final confirmed = await DialogService().showConfirmDialog( + title: 'Keluar', + message: 'Apakah Anda yakin ingin keluar dari aplikasi?', + isDanger: true, + context: context, + ); + + if (confirmed == true) { + try { + await FirebaseService().signOut(); + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => const LoginScreen()), + (route) => false, + ); + } + } catch (e) { + if (mounted) { + DialogService().showError('Gagal keluar: $e'); + } + } + } + } +} diff --git a/lib/screens/recommendation/recommendation_screen.dart b/lib/screens/recommendation/recommendation_screen.dart new file mode 100644 index 0000000..9537a56 --- /dev/null +++ b/lib/screens/recommendation/recommendation_screen.dart @@ -0,0 +1,1988 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/constants/app_constants.dart'; +import '../../models/recommendation_model.dart'; +import '../../services/ocr_service.dart'; +import '../../services/gemini_service.dart'; +import '../../services/firebase_service.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import '../../services/dialog_service.dart'; + +class RecommendationScreen extends ConsumerStatefulWidget { + final RecommendationModel recommendation; + final OCRResult? ocrResult; + + const RecommendationScreen({ + super.key, + required this.recommendation, + this.ocrResult, + }); + + @override + ConsumerState createState() => + _RecommendationScreenState(); +} + +class _RecommendationScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late TabController _tabController; + final TextEditingController _chatController = TextEditingController(); + final List _chatMessages = []; + bool _isLoadingChat = false; + String? _selectedMenuName; + // Menu yang sedang dijadikan konteks di chat (Shopee-style) + MenuRecommendation? _chatContextMenu; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + + // Restore previously selected menu (if any) + _selectedMenuName = widget.recommendation.selectedMenuName; + + // Generate a dynamic greeting based on recommendations + final recommendationsText = widget.recommendation.recommendations + .take(3) + .map((r) => '${r.menuName} (${r.scorePercentage} cocok)') + .join(', '); + + _chatMessages.add( + ChatMessage( + text: + 'Halo! Berdasarkan menu yang saya analisis dan preferensi Anda, saya merekomendasikan: $recommendationsText.\n\nAda yang bisa saya bantu jelaskan lebih lanjut tentang menu-menu ini? Atau Anda punya kriteria lain?', + isUser: false, + ), + ); + } + + @override + void dispose() { + _tabController.dispose(); + _chatController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvoked: (didPop) async { + if (didPop) return; + final shouldPop = await _showBackConfirmation(); + if (shouldPop && mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + }, + child: Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: const Text( + 'Rekomendasi Menu', + style: TextStyle( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: AppTheme.warmWhite, + elevation: 0, + iconTheme: const IconThemeData(color: AppTheme.primaryBrown), + actions: [ + IconButton( + icon: Icon( + widget.recommendation.isFavorite + ? Icons.favorite + : Icons.favorite_border, + color: widget.recommendation.isFavorite + ? AppTheme.error + : AppTheme.primaryBrown, + ), + onPressed: _toggleFavorite, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: AppTheme.primaryBrown), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'share', + child: Row( + children: [ + Icon(Icons.share), + SizedBox(width: 8), + Text('Bagikan'), + ], + ), + ), + const PopupMenuItem( + value: 'save', + child: Row( + children: [ + Icon(Icons.bookmark_border), + SizedBox(width: 8), + Text('Simpan'), + ], + ), + ), + ], + onSelected: (value) { + if (value == 'share') { + _shareRecommendation(); + } else if (value == 'save') { + _saveRecommendation(); + } + }, + ), + ], + bottom: TabBar( + controller: _tabController, + labelColor: AppTheme.primaryBrown, + unselectedLabelColor: AppTheme.grey500, + indicatorColor: AppTheme.primaryBrown, + tabs: const [ + Tab(text: 'Rekomendasi'), + Tab(text: 'Menu Asli'), + Tab(text: 'Chat AI'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + _buildRecommendationTab(), + _buildOriginalMenuTab(), + _buildChatTab(), + ], + ), + ), + ); + } + + Widget _buildRecommendationTab() { + final recs = widget.recommendation.recommendations; + final keinginan = widget.recommendation.recommendations.isNotEmpty + ? _detectKeinginan(recs) + : 'minuman'; + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Card + _buildHeaderCard(), + + const SizedBox(height: 24), + + // Recommendations List + Text( + 'Rekomendasi untuk Anda', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + ), + ), + + const SizedBox(height: 4), + + // Subtitle hint + Text( + keinginan == 'keduanya' + ? 'Slot 1 & 2 adalah pasangan menu yang saling melengkapi โ˜•๐Ÿฝ๏ธ' + : keinginan == 'minuman' + ? '3 pilihan minuman terbaik untuk Anda ๐Ÿฅค' + : '3 pilihan makanan terbaik untuk Anda ๐Ÿฝ๏ธ', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.grey600, + fontStyle: FontStyle.italic, + ), + ), + + const SizedBox(height: 16), + + ...List.generate(recs.length > 3 ? 3 : recs.length, (index) { + final rec = recs[index]; + final slotLabels = keinginan == 'keduanya' + ? ['Paket Utama โญ', 'Kombinasi Alternatif ๐Ÿ”„', 'Paket Hemat ๐Ÿ’ฐ'] + : keinginan == 'minuman' + ? ['Pilihan Terbaik โญ', 'Alternatif ๐Ÿ”„', 'Pilihan Hemat ๐Ÿ’ฐ'] + : ['Pilihan Terbaik โญ', 'Alternatif ๐Ÿ”„', 'Pilihan Hemat ๐Ÿ’ฐ']; + final slotLabel = index < slotLabels.length ? slotLabels[index] : 'Slot ${index + 1}'; + return _buildSlottedRecommendationCard(rec, index + 1, slotLabel, keinginan); + }), + ], + ), + ); + } + + /// Deteksi jenis keinginan dari distribusi rekomendasi + String _detectKeinginan(List recs) { + int drinkCount = 0; + int foodCount = 0; + for (final rec in recs) { + final cat = rec.category.toLowerCase(); + final name = rec.menuName.toLowerCase(); + final isDrink = cat.contains('minuman') || cat.contains('kopi') || cat.contains('teh') || + cat.contains('drink') || name.contains('kopi') || name.contains('coffee') || + name.contains('teh') || name.contains('latte') || name.contains('jus') || + name.contains('milk') || name.contains('matcha') || name.contains('mocha'); + if (isDrink) drinkCount++; + else foodCount++; + } + if (drinkCount > 0 && foodCount > 0) return 'keduanya'; + if (drinkCount > foodCount) return 'minuman'; + return 'makanan'; + } + + Widget _buildSlottedRecommendationCard( + MenuRecommendation recommendation, + int slotNumber, + String slotLabel, + String keinginan, + ) { + // โ”€โ”€ Mapping Type langsung dari AI (Lebih Akurat) โ”€โ”€ + final String type = recommendation.slotType?.toLowerCase() ?? ''; + final bool isMakanan = type.contains('makan'); + final bool isMinuman = type.contains('minum'); + final bool isBundling = type.contains('bundling'); + + // Tampilkan badge tipe menu secara eksplisit jika AI memberikannya + final bool showTypeBadge = isMakanan || isMinuman || isBundling; + + final isSelectedMenu = _selectedMenuName == recommendation.menuName; + + return Column( + children: [ + // Slot Header Label + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: AppTheme.primaryBrown, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '$slotNumber', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 8), + Text( + slotLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryBrown, + ), + ), + if (showTypeBadge) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isBundling ? Colors.green.shade50 : (isMakanan ? Colors.orange.shade50 : Colors.blue.shade50), + border: Border.all(color: isBundling ? Colors.green.shade300 : (isMakanan ? Colors.orange.shade300 : Colors.blue.shade300)), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isBundling ? '๐Ÿฑ Paket Bundling' : (isMakanan ? '๐Ÿฝ๏ธ Makanan' : 'โ˜• Minuman'), + style: TextStyle( + fontSize: 11, + color: isBundling ? Colors.green.shade700 : (isMakanan ? Colors.orange.shade700 : Colors.blue.shade700), + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ), + + // The actual recommendation card + _buildRecommendationCard(recommendation), + ], + ); + } + + + Widget _buildHeaderCard() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppTheme.primaryBrown, AppTheme.coffeeBrown], + ), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: AppTheme.primaryBrown.withOpacity(0.3), + blurRadius: 15, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_awesome, color: Colors.white, size: 24) + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 2000.ms, + color: Colors.white.withOpacity(0.5), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Rekomendasi Personal', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${(widget.recommendation.confidence * 100).toInt()}% Match', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Berdasarkan ${widget.recommendation.recommendations.length} menu yang terdeteksi', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.white.withOpacity(0.9), + ), + ), + const SizedBox(height: 8), + Text( + widget.recommendation.formattedDate, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white.withOpacity(0.7), + ), + ), + ], + ), + ).animate().scale(duration: 600.ms, curve: Curves.easeOutBack); + } + + Widget _buildRecommendationCard(MenuRecommendation recommendation) { + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _selectedMenuName == recommendation.menuName + ? Colors.green.shade400 + : AppTheme.primaryBrown.withOpacity(0.08), + width: _selectedMenuName == recommendation.menuName ? 2 : 1, + ), + boxShadow: [ + BoxShadow( + color: _selectedMenuName == recommendation.menuName + ? Colors.green.withOpacity(0.12) + : AppTheme.primaryBrown.withOpacity(0.06), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title Header with gradient accent + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + AppTheme.primaryBrown.withOpacity(0.08), + AppTheme.primaryBrown.withOpacity(0.02), + ], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + // Coffee icon + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _getCategoryIcon(recommendation.category), + color: AppTheme.primaryBrown, + size: 20, + ), + ), + const SizedBox(width: 12), + // Title & Price + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recommendation.menuName, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 2), + Text( + recommendation.formattedPrice, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.coffeeBrown, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + // Score badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppTheme.primaryBrown, AppTheme.coffeeBrown], + ), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.star_rounded, + color: Colors.amber, + size: 14, + ), + const SizedBox(width: 3), + Text( + recommendation.scorePercentage, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + + // Body content + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Star rating row + Row( + children: [ + ...List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.only(right: 2), + child: Icon( + index < recommendation.starRating + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: index < recommendation.starRating + ? Colors.amber + : AppTheme.grey400, + size: 18, + ), + ); + }), + const SizedBox(width: 8), + Text( + 'Kesesuaian ${recommendation.scorePercentage}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.grey600, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Tags + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _buildTag(recommendation.category, AppTheme.primaryBrown), + ...recommendation.tags.map( + (tag) => _buildTag(tag, AppTheme.grey600), + ), + ], + ), + + // Description + if (recommendation.description.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + recommendation.description, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.grey700, + height: 1.5, + ), + ), + ], + + const SizedBox(height: 12), + + // Personalized reason + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppTheme.primaryBrown.withOpacity(0.06), + AppTheme.accent.withOpacity(0.04), + ], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.primaryBrown.withOpacity(0.08), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.auto_awesome, + size: 14, + color: AppTheme.primaryBrown, + ), + ), + const SizedBox(width: 6), + Text( + 'Mengapa cocok untuk Anda', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + recommendation.reason, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.grey700, + height: 1.4, + ), + ), + ], + ), + ), + + const SizedBox(height: 14), + + // Action Buttons โ€” Pilih Menu + Tanya AI + Row( + children: [ + // Pilih Menu + Expanded( + child: ElevatedButton( + onPressed: () => _orderItem(recommendation), + style: ElevatedButton.styleFrom( + backgroundColor: + _selectedMenuName == recommendation.menuName + ? Colors.green.shade600 + : AppTheme.primaryBrown, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _selectedMenuName == recommendation.menuName + ? Icons.check_circle_outline + : Icons.shopping_cart_outlined, + size: 16, + ), + const SizedBox(width: 6), + Text( + _selectedMenuName == recommendation.menuName + ? 'Dipilih โœ“' + : 'Pilih Menu', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 10), + // Tanya AI tentang menu ini + Expanded( + child: OutlinedButton( + onPressed: () => _askAboutMenu(recommendation), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.primaryBrown, + side: BorderSide( + color: AppTheme.primaryBrown.withOpacity(0.6), + width: 1.5, + ), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.chat_bubble_outline_rounded, size: 16), + SizedBox(width: 6), + Text( + 'Tanya AI', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + IconData _getCategoryIcon(String category) { + final lower = category.toLowerCase(); + if (lower.contains('coffee') || lower.contains('kopi')) return Icons.coffee; + if (lower.contains('tea') || lower.contains('teh')) + return Icons.emoji_food_beverage; + if (lower.contains('food') || lower.contains('makanan')) + return Icons.restaurant; + if (lower.contains('drink') || lower.contains('minuman')) + return Icons.local_drink; + if (lower.contains('dessert') || lower.contains('snack')) return Icons.cake; + return Icons.local_cafe; + } + + Widget _buildTag(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + text, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + /// Bangun section foto menu. Mendukung path lokal (Image.file) dan URL (Image.network). + /// Jika ada menuImagePaths (multi-foto), tampilkan sebagai carousel horizontal. + List _buildPhotoSection(String? fallbackUrl) { + final rec = widget.recommendation; + + final List paths = []; + if (rec.menuImagePaths != null && rec.menuImagePaths!.isNotEmpty) { + paths.addAll(rec.menuImagePaths!); + } else if (fallbackUrl != null && fallbackUrl.isNotEmpty) { + paths.add(fallbackUrl); + } + + if (paths.isEmpty) return []; + + final pageController = PageController(); + + return [ + Text( + paths.length > 1 ? 'Foto Menu (${paths.length})' : 'Foto Menu', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF3C2415), + letterSpacing: -0.3, + ), + ), + const SizedBox(height: 12), + // Hint tap to expand + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: 220, + child: PageView.builder( + controller: pageController, + itemCount: paths.length, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => _openFullscreen(paths, index), + child: _buildSinglePhotoWidget(paths[index]), + ); + }, + ), + ), + ), + // Expand icon overlay (top-right) + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => _openFullscreen(paths, 0), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.45), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.fullscreen_rounded, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ), + if (paths.length > 1) ...[ + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(paths.length, (i) { + return AnimatedBuilder( + animation: pageController, + builder: (context, _) { + final page = pageController.hasClients + ? (pageController.page ?? 0).round() + : 0; + final isActive = page == i; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + width: isActive ? 18 : 6, + height: 6, + decoration: BoxDecoration( + color: isActive + ? const Color(0xFF8B5E3C) + : const Color(0xFFD4CFC8), + borderRadius: BorderRadius.circular(3), + ), + ); + }, + ); + }), + ), + ], + const SizedBox(height: 24), + ]; + } + + void _openFullscreen(List paths, int initialIndex) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.black, + pageBuilder: (_, __, ___) => + _FullscreenPhotoViewer(paths: paths, initialIndex: initialIndex), + transitionsBuilder: (_, animation, __, child) => + FadeTransition(opacity: animation, child: child), + transitionDuration: const Duration(milliseconds: 220), + ), + ); + } + + Widget _buildSinglePhotoWidget(String pathOrUrl) { + // Deteksi apakah ini path lokal atau URL + final isLocalFile = !pathOrUrl.startsWith('http'); + final errorWidget = Container( + height: 220, + decoration: const BoxDecoration(color: Color(0xFFF0EBE4)), + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.image_not_supported_outlined, + color: Color(0xFFBDB5AB), + size: 32, + ), + SizedBox(height: 6), + Text( + 'Gambar tidak tersedia', + style: TextStyle(color: Color(0xFFBDB5AB), fontSize: 12), + ), + ], + ), + ), + ); + + if (isLocalFile) { + final file = File(pathOrUrl); + return file.existsSync() + ? Image.file( + file, + width: double.infinity, + height: 220, + fit: BoxFit.cover, + ) + : errorWidget; + } else { + return Image.network( + pathOrUrl, + width: double.infinity, + height: 220, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => errorWidget, + ); + } + } + + Widget _buildOriginalMenuTab() { + final menuItems = widget.recommendation.recommendations; + final menuImageUrl = widget.recommendation.menuImageUrl; + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // โ”€โ”€ Foto Menu Section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ..._buildPhotoSection(menuImageUrl), + + // Stats Bar + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFAF6F1), Color(0xFFF5EDE3)], + ), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE8DFD4)), + ), + child: Row( + children: [ + const Icon( + Icons.restaurant_menu_rounded, + color: Color(0xFF8B5E3C), + size: 20, + ), + const SizedBox(width: 8), + Text( + '${menuItems.length} menu terdeteksi', + style: const TextStyle( + color: Color(0xFF6B3A2A), + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const Spacer(), + if (widget.ocrResult != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFF6B3A2A).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'OCR ${((widget.ocrResult?.confidence ?? 0) * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF6B3A2A), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Section Title + Row( + children: [ + Container( + width: 4, + height: 20, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF8B5E3C), Color(0xFFD4A574)], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + 'Daftar Menu', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + color: const Color(0xFF3C2415), + letterSpacing: -0.3, + ), + ), + ], + ), + + const SizedBox(height: 14), + + // Menu Items List + if (menuItems.isEmpty) + _buildEmptyMenuState() + else + ...menuItems.asMap().entries.map( + (entry) => _buildMenuDetailCard(entry.value, entry.key + 1), + ), + + const SizedBox(height: 20), + + // Raw Text Section (collapsible) + if (widget.recommendation.originalMenuText.isNotEmpty) + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFF0EBE4)), + ), + child: Theme( + data: Theme.of( + context, + ).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + leading: const Icon( + Icons.text_snippet_outlined, + color: Color(0xFF9E8E7E), + size: 20, + ), + title: const Text( + 'Teks Menu Asli', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF6B3A2A), + ), + ), + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFAF6F1), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + widget.recommendation.originalMenuText, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Color(0xFF6B5B4B), + height: 1.6, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyMenuState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFF0EBE4)), + ), + child: const Column( + children: [ + Text('๐Ÿฝ๏ธ', style: TextStyle(fontSize: 36)), + SizedBox(height: 12), + Text( + 'Tidak ada menu terdeteksi', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF6B3A2A), + fontSize: 15, + ), + ), + SizedBox(height: 4), + Text( + 'Coba scan ulang dengan foto yang lebih jelas', + style: TextStyle(color: Color(0xFF9E8E7E), fontSize: 12), + ), + ], + ), + ); + } + + Widget _buildMenuDetailCard(MenuRecommendation item, int index) { + final score = (item.score * 100).toInt(); + final isSelected = widget.recommendation.selectedMenuName == item.menuName; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isSelected + ? const Color(0xFFD4A574).withOpacity(0.4) + : const Color(0xFFF0EBE4), + ), + boxShadow: [ + BoxShadow( + color: isSelected + ? const Color(0xFF8B5E3C).withOpacity(0.08) + : Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row with number, name, price + Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: isSelected + ? [ + const Color(0xFF6B3A2A).withOpacity(0.06), + const Color(0xFFD4A574).withOpacity(0.08), + ] + : [ + const Color(0xFFFAF6F1), + const Color(0xFFFAF6F1).withOpacity(0.5), + ], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + ), + ), + child: Row( + children: [ + // Number badge + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF8B5E3C), Color(0xFF6B3A2A)], + ), + borderRadius: BorderRadius.circular(9), + ), + child: Center( + child: Text( + '$index', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), + ), + ), + const SizedBox(width: 12), + // Name + Expanded( + child: Text( + item.menuName, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFF3C2415), + letterSpacing: -0.3, + ), + ), + ), + // Price + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: const Color(0xFF6B3A2A).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.formattedPrice, + style: const TextStyle( + color: Color(0xFF6B3A2A), + fontWeight: FontWeight.w800, + fontSize: 14, + ), + ), + ), + ], + ), + ), + + // Details body + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category + Score Row + Row( + children: [ + _buildInfoChip( + icon: _getCategoryIcon(item.category), + label: item.category, + color: const Color(0xFF8B5E3C), + ), + const SizedBox(width: 8), + _buildInfoChip( + icon: Icons.star_rounded, + label: '$score%', + color: score >= 80 + ? const Color(0xFFE5A100) + : const Color(0xFF9E8E7E), + ), + if (isSelected) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF43A047), Color(0xFF66BB6A)], + ), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + color: Colors.white, + size: 12, + ), + SizedBox(width: 3), + Text( + 'Dipilih', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ], + ), + + // Description + if (item.description.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + item.description, + style: const TextStyle( + color: Color(0xFF7B6B5B), + fontSize: 13, + height: 1.5, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + + // Tags + if (item.tags.isNotEmpty) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + runSpacing: 6, + children: item.tags + .map( + (tag) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFF5EDE3), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + tag, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF8B7B6B), + fontWeight: FontWeight.w500, + ), + ), + ), + ) + .toList(), + ), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoChip({ + required IconData icon, + required String label, + required Color color, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ); + } + + Widget _buildChatTab() { + return Column( + children: [ + // Chat Messages + Expanded( + child: _chatMessages.isEmpty + ? _buildChatPlaceholder() + : ListView.builder( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + itemCount: _chatMessages.length, + itemBuilder: (context, index) { + return _buildChatMessage(_chatMessages[index]); + }, + ), + ), + + // Chat Input (includes context banner if _chatContextMenu != null) + _buildChatInput(), + ], + ); + } + + Widget _buildChatPlaceholder() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.chat_bubble_outline, size: 64, color: AppTheme.grey400), + const SizedBox(height: 16), + Text( + 'Halo! Senang sekali bisa membantu Anda memilih menu di Amori Kofie Kediri.', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.grey800, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Ajukan pertanyaan tentang menu, alergi, atau preferensi lainnya', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.grey500), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildChatMessage(ChatMessage message) { + final isUser = message.isUser; + return Container( + margin: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: isUser + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isUser) ...[ + CircleAvatar( + radius: 16, + backgroundColor: AppTheme.primaryBrown, + child: const Icon(Icons.smart_toy, size: 16, color: Colors.white), + ), + const SizedBox(width: 8), + ], + Flexible( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isUser ? AppTheme.primaryBrown : Colors.white, + borderRadius: BorderRadius.circular(16).copyWith( + bottomLeft: isUser + ? const Radius.circular(16) + : const Radius.circular(4), + bottomRight: isUser + ? const Radius.circular(4) + : const Radius.circular(16), + ), + border: isUser + ? null + : Border.all(color: AppTheme.grey300, width: 1), + boxShadow: isUser + ? null + : [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + message.text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isUser ? Colors.white : Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + if (isUser) ...[ + const SizedBox(width: 8), + CircleAvatar( + radius: 16, + backgroundColor: AppTheme.grey300, + child: const Icon(Icons.person, size: 16, color: Colors.white), + ), + ], + ], + ), + ).animate().fade().slideY(begin: 0.2, end: 0, curve: Curves.easeOut); + } + + Widget _buildChatInput() { + return Container( + color: Colors.white, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // โ”€โ”€ Shopee-style context banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (_chatContextMenu != null) + _buildChatContextBanner(_chatContextMenu!), + + // โ”€โ”€ Input row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Container( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: _chatContextMenu == null + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ] + : [], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _chatController, + style: const TextStyle(color: Colors.black, fontSize: 16), + decoration: InputDecoration( + hintText: _chatContextMenu != null + ? 'Tanya tentang ${_chatContextMenu!.menuName}...' + : 'Tanya tentang menu...', + hintStyle: TextStyle( + color: AppTheme.grey500, + fontSize: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: AppTheme.grey50, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + maxLines: null, + textCapitalization: TextCapitalization.sentences, + autofocus: _chatContextMenu != null, + ), + ), + const SizedBox(width: 8), + Container( + decoration: const BoxDecoration( + color: AppTheme.primaryBrown, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: _isLoadingChat ? null : _sendMessage, + icon: _isLoadingChat + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Icon(Icons.send, color: Colors.white), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Banner konteks menu โ€” seperti produk yang diklik di Shopee chat + Widget _buildChatContextBanner(MenuRecommendation menu) { + return Container( + margin: const EdgeInsets.fromLTRB(12, 6, 12, 0), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppTheme.primaryBrown.withOpacity(0.2)), + ), + child: Row( + children: [ + // Ikon kategori + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _getCategoryIcon(menu.category), + color: AppTheme.primaryBrown, + size: 20, + ), + ), + const SizedBox(width: 10), + // Info menu + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + menu.menuName, + style: const TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w700, + fontSize: 13, + color: Color(0xFF3C2415), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + menu.formattedPrice, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppTheme.primaryBrown, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppTheme.primaryBrown.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + menu.scorePercentage, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 10, + color: AppTheme.primaryBrown, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + // Tombol dismiss + GestureDetector( + onTap: () => setState(() => _chatContextMenu = null), + child: Container( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.close_rounded, + size: 16, + color: AppTheme.primaryBrown.withOpacity(0.6), + ), + ), + ), + ], + ), + ); + } + + /// Tanya AI tentang menu tertentu โ€” Shopee-style: + /// Tidak auto-send, cukup set konteks menu dan pindah ke tab Chat AI. + void _askAboutMenu(MenuRecommendation recommendation) { + setState(() => _chatContextMenu = recommendation); + // Pindah ke tab Chat AI (index 2) + _tabController.animateTo(2); + } + + Future _sendMessage() async { + final message = _chatController.text.trim(); + if (message.isEmpty) return; + + // Jika ada konteks menu, sertakan di pesan + final contextNote = _chatContextMenu != null + ? ' [Konteks: ${_chatContextMenu!.menuName} โ€“ ${_chatContextMenu!.formattedPrice}]' + : ''; + final fullMessage = message + contextNote; + + setState(() { + // Tampilkan pesan user tanpa note konteks + _chatMessages.add(ChatMessage(text: message, isUser: true)); + _isLoadingChat = true; + // Hapus konteks setelah dikirim (seperti Shopee) + _chatContextMenu = null; + }); + + _chatController.clear(); + + try { + final response = await GeminiService().chatWithAI( + message: fullMessage, // Kirim pesan lengkap dengan konteks menu ke AI + context: widget.recommendation, + conversationHistory: _chatMessages + .where((msg) => !msg.isUser) + .map((msg) => msg.text) + .toList(), + ); + + setState(() { + _chatMessages.add(ChatMessage(text: response, isUser: false)); + }); + } catch (e) { + setState(() { + _chatMessages.add( + ChatMessage( + text: 'Maaf, terjadi kesalahan. Silakan coba lagi.', + isUser: false, + ), + ); + }); + } finally { + setState(() => _isLoadingChat = false); + } + } + + Future _toggleFavorite() async { + try { + final newFavoriteStatus = !widget.recommendation.isFavorite; + await FirebaseService().toggleRecommendationFavorite( + widget.recommendation.id, + newFavoriteStatus, + ); + + setState(() { + widget.recommendation.copyWith(isFavorite: newFavoriteStatus); + }); + + DialogService().showSuccess( + newFavoriteStatus ? 'Ditambahkan ke favorit' : 'Dihapus dari favorit', + ); + } catch (e) { + DialogService().showError('Gagal mengupdate favorit: $e'); + } + } + + void _shareRecommendation() { + // Implement share functionality + DialogService().showInfo('Fitur berbagi akan segera hadir'); + } + + void _saveRecommendation() { + DialogService().showSuccess('Rekomendasi telah disimpan'); + } + + void _orderItem(MenuRecommendation recommendation) async { + try { + // Toggle selection: tap again to deselect + final isAlreadySelected = _selectedMenuName == recommendation.menuName; + final newName = isAlreadySelected ? null : recommendation.menuName; + + setState(() => _selectedMenuName = newName); + + // Update the recommendation model with selected menu + widget.recommendation.selectedMenuName = newName; + + // Save to Firebase + await FirebaseService().saveRecommendation(widget.recommendation); + + if (mounted) { + if (isAlreadySelected) { + DialogService().showInfo('Pilihan dibatalkan.'); + } else { + DialogService().showSuccess( + '${recommendation.menuName} dipilih! Tunjukkan ke kasir untuk memesan.', + ); + } + } + } catch (e) { + debugPrint('Error saving selected menu: $e'); + if (mounted) { + DialogService().showError('Gagal menyimpan pilihan: $e'); + } + } + } + + Future _showBackConfirmation() async { + final bool isSelected = _selectedMenuName != null; + final String title = isSelected ? 'Kembali ke Beranda?' : 'Batalkan Rekomendasi?'; + final String content = isSelected + ? 'Menu telah dipilih. Rekomendasi ini sudah otomatis tersimpan di riwayat Beranda Anda.' + : 'Anda belum memilih menu rekomendasi satupun. Jika Anda kembali, hasil rekomendasi ini hanya akan tersimpan sebagai draf di Riwayat Scan Menu, namun tidak akan muncul di Riwayat Beranda. Yakin ingin kembali?'; + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold, color: AppTheme.primaryBrown), + ), + content: Text( + content, + style: const TextStyle(height: 1.4, color: AppTheme.grey800), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Batal', style: TextStyle(color: AppTheme.grey600)), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? AppTheme.primaryBrown : AppTheme.error, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text(isSelected ? 'Ya, Kembali' : 'Ya, Batalkan'), + ), + ], + ), + ); + + return result ?? false; + } +} + +class ChatMessage { + final String text; + final bool isUser; + final DateTime timestamp; + + ChatMessage({required this.text, required this.isUser, DateTime? timestamp}) + : timestamp = timestamp ?? DateTime.now(); +} + +/// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +/// Fullscreen photo viewer โ€” swipe antar foto, pinch-to-zoom, tap to close +/// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class _FullscreenPhotoViewer extends StatefulWidget { + final List paths; + final int initialIndex; + + const _FullscreenPhotoViewer({ + required this.paths, + required this.initialIndex, + }); + + @override + State<_FullscreenPhotoViewer> createState() => _FullscreenPhotoViewerState(); +} + +class _FullscreenPhotoViewerState extends State<_FullscreenPhotoViewer> { + late PageController _pageController; + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // โ”€โ”€ Foto carousel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + PageView.builder( + controller: _pageController, + itemCount: widget.paths.length, + onPageChanged: (i) => setState(() => _currentIndex = i), + itemBuilder: (ctx, index) { + return GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Center( + child: InteractiveViewer( + minScale: 0.8, + maxScale: 5.0, + child: _buildPhoto(widget.paths[index]), + ), + ), + ); + }, + ), + + // โ”€โ”€ Top bar: close button + counter โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Counter badge + if (widget.paths.length > 1) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.55), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_currentIndex + 1} / ${widget.paths.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + fontFamily: 'Poppins', + ), + ), + ) + else + const SizedBox.shrink(), + // Close button + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.55), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close_rounded, + color: Colors.white, + size: 20, + ), + ), + ), + ], + ), + ), + ), + ), + + // โ”€โ”€ Dot indicators (bottom) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (widget.paths.length > 1) + Positioned( + bottom: 32, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.paths.length, (i) { + final active = i == _currentIndex; + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: active ? 20 : 7, + height: 7, + decoration: BoxDecoration( + color: active + ? Colors.white + : Colors.white.withOpacity(0.4), + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ), + ), + ], + ), + ); + } + + Widget _buildPhoto(String pathOrUrl) { + final isLocal = !pathOrUrl.startsWith('http'); + final error = const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.broken_image_outlined, color: Colors.white38, size: 48), + SizedBox(height: 8), + Text( + 'Gambar tidak tersedia', + style: TextStyle(color: Colors.white38, fontFamily: 'Poppins'), + ), + ], + ), + ); + + if (isLocal) { + final file = File(pathOrUrl); + return file.existsSync() ? Image.file(file, fit: BoxFit.contain) : error; + } else { + return Image.network( + pathOrUrl, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => error, + ); + } + } +} diff --git a/lib/screens/settings/api_key_settings_screen.dart b/lib/screens/settings/api_key_settings_screen.dart new file mode 100644 index 0000000..ea46950 --- /dev/null +++ b/lib/screens/settings/api_key_settings_screen.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../services/gemini_service.dart'; +import '../../core/theme/app_theme.dart'; +import '../../services/dialog_service.dart'; + +class ApiKeySettingsScreen extends ConsumerStatefulWidget { + const ApiKeySettingsScreen({super.key}); + + @override + ConsumerState createState() => + _ApiKeySettingsScreenState(); +} + +class _ApiKeySettingsScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _apiKeyController = TextEditingController(); + bool _isLoading = false; + bool _obscureText = true; + + @override + void initState() { + super.initState(); + _loadCurrentApiKeyInfo(); + } + + void _loadCurrentApiKeyInfo() { + // Display current API key info + setState(() {}); + } + + Future _updateApiKey() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + }); + + try { + await GeminiService().updateApiKey(_apiKeyController.text.trim()); + + if (mounted) { + DialogService().showSuccess('API key berhasil diperbarui!'); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + Future _resetToDefault() async { + setState(() { + _isLoading = true; + }); + + try { + await GeminiService().resetToDefaultApiKey(); + + if (mounted) { + DialogService().showInfo('Kembali menggunakan API key default'); + Navigator.of(context).pop(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + void dispose() { + _apiKeyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Pengaturan API Key'), + backgroundColor: AppTheme.primaryBrown, + foregroundColor: Colors.white, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Current API Key Info + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Status API Key Saat Ini', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + GeminiService().currentApiKeyInfo, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // Information Card + Card( + color: Colors.blue.shade50, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info, color: Colors.blue.shade700), + const SizedBox(width: 8), + const Text( + 'Informasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Anda dapat menggunakan API key Gemini pribadi untuk:\n' + 'โ€ข Menghindari pembatasan quota\n' + 'โ€ข Kontrol penggunaan sendiri\n' + 'โ€ข Performa yang lebih stabil\n\n' + 'Dapatkan API key gratis di: https://makersuite.google.com/app/apikey', + style: TextStyle(fontSize: 14), + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // API Key Input + const Text( + 'Masukkan API Key Gemini Anda', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Builder( + builder: (context) { + return TextFormField( + controller: _apiKeyController, + obscureText: _obscureText, + onTap: () { + Future.delayed(const Duration(milliseconds: 300), () { + if (context.mounted) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + }); + }, + decoration: InputDecoration( + hintText: 'AIzaSy...', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscureText + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'API key tidak boleh kosong'; + } + if (!value.trim().startsWith('AIzaSy')) { + return 'Format API key tidak valid'; + } + return null; + }, + ); + }, + ), + + const SizedBox(height: 24), + + // Action Buttons + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _isLoading ? null : _updateApiKey, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBrown, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Text('Gunakan API Key Ini'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : _resetToDefault, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('Gunakan Default'), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // Help Text + Text( + 'Catatan: API key Anda disimpan secara lokal dan tidak dikirim ke server kami.', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart new file mode 100644 index 0000000..6623fd9 --- /dev/null +++ b/lib/screens/splash_screen.dart @@ -0,0 +1,315 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/firebase_service.dart'; +import 'auth/login_screen.dart'; +import 'home/home_screen.dart'; +import 'onboarding_screen.dart'; +import 'onboarding/early_setup_screen.dart'; + +class SplashScreen extends ConsumerStatefulWidget { + const SplashScreen({super.key}); + + @override + ConsumerState createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + @override + void initState() { + super.initState(); + _initializeApp(); + } + + Future _initializeApp() async { + try { + debugPrint('๐Ÿš€ Starting app initialization...'); + + // Start minimum timer + final minTimer = Future.delayed(const Duration(seconds: 3)); + + // Attempt to load resources with a timeout limit + try { + await Future.any([ + Future.wait([ + _checkConnectivity(), + _checkServerHealth(), + _preloadData(), + _precacheNetworkImages(), + ]), + Future.delayed(const Duration(seconds: 5)), + ]); + } catch (e) { + debugPrint('Resource loading error/timeout: $e'); + } + + // Ensure minimum splash time has passed + await minTimer; + + debugPrint('โœ… Splash timer finished, navigating...'); + + if (mounted) { + // Check if onboarding has been completed + final prefs = await SharedPreferences.getInstance(); + final isFirstLaunch = prefs.getBool('is_first_launch') ?? true; + + if (isFirstLaunch) { + // FIRST LAUNCH: Always show Onboarding, sign out any stale sessions + debugPrint('๐Ÿ†• First launch -> Showing Onboarding'); + final user = FirebaseService().currentUser; + if (user != null) { + debugPrint('โš ๏ธ Stale auth session found -> Signing out'); + await FirebaseService().signOut(); + } + if (mounted) { + Navigator.of(context).pushReplacement( + PageRouteBuilder( + pageBuilder: (_, __, ___) => const OnboardingScreen(), + transitionDuration: const Duration(milliseconds: 500), + transitionsBuilder: (_, a, __, c) => + FadeTransition(opacity: a, child: c), + ), + ); + } + return; + } + + final user = FirebaseService().currentUser; + debugPrint( + '๐Ÿ‘ค User status: ${user != null ? "Logged In (${user.uid})" : "Guest"}', + ); + + if (user != null) { + // Check if user data exists and setup is complete + final userData = await FirebaseService().getUserData(user.uid); + + if (userData == null) { + debugPrint('โš ๏ธ User data missing -> Forcing Sign Out'); + await FirebaseService().signOut(); + if (mounted) { + Navigator.of(context).pushReplacement( + PageRouteBuilder( + pageBuilder: (_, __, ___) => const OnboardingScreen(), + transitionDuration: const Duration(milliseconds: 500), + transitionsBuilder: (_, a, __, c) => + FadeTransition(opacity: a, child: c), + ), + ); + } + } else if (!userData.isSetupComplete) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const EarlySetupScreen()), + ); + } else { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const HomeScreen()), + ); + } + } else { + Navigator.of(context).pushReplacement( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + const OnboardingScreen(), + transitionDuration: const Duration(milliseconds: 500), + transitionsBuilder: + (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation.drive( + Tween( + begin: 0.0, + end: 1.0, + ).chain(CurveTween(curve: Curves.easeOut)), + ), + child: child, + ); + }, + ), + ); + } + } + } catch (e) { + debugPrint('Splash initialization error: $e'); + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const LoginScreen()), + ); + } + } + } + + /// Check network connectivity silently in background + Future _checkConnectivity() async { + try { + final result = await Connectivity().checkConnectivity(); + + final isConnected = result != ConnectivityResult.none; + debugPrint( + '๐ŸŒ Network connectivity: ${isConnected ? "Connected" : "Offline"}', + ); + + return isConnected; + } catch (e) { + debugPrint('โš ๏ธ Connectivity check failed: $e'); + return false; + } + } + + /// Verify server health and API accessibility + Future _checkServerHealth() async { + try { + // Get API URL from config + final apiUrl = 'https://dev-api.muningkofie.com'; + + // Send health check request with timeout + final response = await http + .get(Uri.parse('$apiUrl/health')) + .timeout( + const Duration(seconds: 5), + onTimeout: () { + debugPrint('โš ๏ธ Server health check timeout'); + return http.Response('Timeout', 408); + }, + ); + + final isHealthy = response.statusCode == 200; + debugPrint( + '๐Ÿฅ Server health: ${isHealthy ? "OK" : "Unavailable (${response.statusCode})"}', + ); + + return isHealthy; + } catch (e) { + debugPrint('โš ๏ธ Server health check failed: $e'); + return false; + } + } + + /// Preload essential data and resources + Future _preloadData() async { + try { + debugPrint('๐Ÿ“ฆ Preloading essential resources...'); + + // Preload user preferences from local storage + await _loadUserPreferences(); + + // Cache important assets + await _precacheAssets(); + + debugPrint('โœ… Resources preloaded successfully'); + } catch (e) { + debugPrint('โš ๏ธ Resource preloading failed: $e'); + } + } + + /// Load user preferences from SharedPreferences + Future _loadUserPreferences() async { + try { + final sharedPrefs = await SharedPreferences.getInstance(); + + // Check if first launch + final isFirstLaunch = sharedPrefs.getBool('is_first_launch') ?? true; + debugPrint('๐Ÿ“ฑ First launch: $isFirstLaunch'); + } catch (e) { + debugPrint('โš ๏ธ Failed to load preferences: $e'); + } + } + + /// Precache important image assets + Future _precacheAssets() async { + try { + if (mounted) { + // Precache commonly used images to improve performance + // Currently only GIF is being used, but can add more + await precacheImage( + const AssetImage('assets/animations/loginsplashscreen.gif'), + context, + ); + } + } catch (e) { + debugPrint('โš ๏ธ Asset precaching failed: $e'); + } + } + + /// Precache network images for faster loading on subsequent screens + Future _precacheNetworkImages() async { + try { + if (mounted) { + debugPrint('๐ŸŒ Precaching network images...'); + + // Google logo for login screen - use ImageStream to ensure full load + const googleLogoUrl = + 'https://developers.google.com/identity/images/g-logo.png'; + + final imageProvider = NetworkImage(googleLogoUrl); + final completer = Completer(); + + final imageStream = imageProvider.resolve(ImageConfiguration.empty); + late ImageStreamListener listener; + + listener = ImageStreamListener( + (ImageInfo info, bool synchronousCall) { + debugPrint( + 'โœ… Google logo loaded: ${info.image.width}x${info.image.height}', + ); + imageStream.removeListener(listener); + if (!completer.isCompleted) completer.complete(); + }, + onError: (exception, stackTrace) { + debugPrint('โš ๏ธ Failed to load Google logo: $exception'); + imageStream.removeListener(listener); + if (!completer.isCompleted) completer.complete(); + }, + ); + + imageStream.addListener(listener); + + // Wait for image to fully load + await completer.future; + + // Also precache using Flutter's built-in method for caching + if (mounted) { + await precacheImage(imageProvider, context); + } + + debugPrint('โœ… Network images precached successfully'); + } + } catch (e) { + debugPrint('โš ๏ธ Network image precaching failed: $e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 300, + height: 300, + child: Image.asset( + 'assets/animations/loginsplashscreen.gif', + fit: BoxFit.contain, + ), + ) + .animate() + .fade(duration: 800.ms) + .scale( + delay: 200.ms, + duration: 800.ms, + curve: Curves.easeOutBack, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/testing/sus_questionnaire_screen.dart b/lib/screens/testing/sus_questionnaire_screen.dart new file mode 100644 index 0000000..f5bb564 --- /dev/null +++ b/lib/screens/testing/sus_questionnaire_screen.dart @@ -0,0 +1,417 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/constants/app_constants.dart'; + +class SUSQuestionnaireScreen extends ConsumerStatefulWidget { + const SUSQuestionnaireScreen({super.key}); + + @override + ConsumerState createState() => + _SUSQuestionnaireScreenState(); +} + +class _SUSQuestionnaireScreenState + extends ConsumerState { + final List _responses = List.filled(10, 0); + bool _isCompleted = false; + + final List _questions = [ + "Saya pikir saya akan sering menggunakan aplikasi ini", + "Saya merasa aplikasi ini terlalu rumit untuk digunakan", + "Saya pikir aplikasi ini mudah digunakan", + "Saya pikir saya membutuhkan bantuan teknis untuk menggunakan aplikasi ini", + "Saya merasa berbagai fitur dalam aplikasi ini terintegrasi dengan baik", + "Saya pikir ada terlalu banyak ketidakkonsistenan dalam aplikasi ini", + "Saya pikir kebanyakan orang akan belajar menggunakan aplikasi ini dengan cepat", + "Saya merasa aplikasi ini sangat merepotkan untuk digunakan", + "Saya merasa sangat percaya diri menggunakan aplikasi ini", + "Saya perlu belajar banyak hal sebelum bisa menggunakan aplikasi ini dengan baik", + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.warmWhite, + appBar: AppBar( + title: const Text('System Usability Scale (SUS)'), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppConstants.defaultPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + _buildHeader(), + + const SizedBox(height: 24), + + // Questions + ..._buildQuestions(), + + const SizedBox(height: 32), + + // Submit Button + _buildSubmitButton(), + + if (_isCompleted) ...[const SizedBox(height: 24), _buildResults()], + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.info.withOpacity(0.1), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + border: Border.all(color: AppTheme.info.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.quiz_outlined, color: AppTheme.info, size: 24), + const SizedBox(width: 8), + Text( + 'Evaluasi Kegunaan Sistem', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.info, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Mohon berikan penilaian Anda terhadap aplikasi AMORI Menu Recommender berdasarkan pengalaman penggunaan. Skala penilaian dari 1 (Sangat Tidak Setuju) hingga 5 (Sangat Setuju).', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppTheme.grey700), + ), + ], + ), + ); + } + + List _buildQuestions() { + return _questions.asMap().entries.map((entry) { + final index = entry.key; + final question = entry.value; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pertanyaan ${index + 1}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + question, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + // Rating Scale + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sangat\nTidak Setuju', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.grey600), + textAlign: TextAlign.center, + ), + Text( + 'Sangat\nSetuju', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.grey600), + textAlign: TextAlign.center, + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(5, (rating) { + final value = rating + 1; + final isSelected = _responses[index] == value; + + return GestureDetector( + onTap: () { + setState(() { + _responses[index] = value; + }); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? AppTheme.primaryBrown + : Colors.transparent, + border: Border.all( + color: isSelected + ? AppTheme.primaryBrown + : AppTheme.grey300, + width: 2, + ), + ), + child: Center( + child: Text( + value.toString(), + style: TextStyle( + color: isSelected ? Colors.white : AppTheme.grey600, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + }), + ), + ], + ), + ); + }).toList(); + } + + Widget _buildSubmitButton() { + final allAnswered = !_responses.contains(0); + + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: allAnswered ? _calculateSUSScore : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: allAnswered + ? AppTheme.primaryBrown + : AppTheme.grey300, + ), + child: Text( + _isCompleted ? 'Selesai' : 'Hitung Skor SUS', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + ); + } + + Widget _buildResults() { + final score = _calculateSUSScore(); + final interpretation = _interpretSUSScore(score); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppTheme.success.withOpacity(0.1), + AppTheme.info.withOpacity(0.1), + ], + ), + borderRadius: BorderRadius.circular(AppConstants.borderRadius), + border: Border.all(color: AppTheme.success.withOpacity(0.3)), + ), + child: Column( + children: [ + Icon(Icons.analytics_outlined, size: 48, color: AppTheme.success), + const SizedBox(height: 16), + Text( + 'Hasil SUS Score', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.success, + ), + ), + const SizedBox(height: 8), + Text( + score.toStringAsFixed(1), + style: Theme.of(context).textTheme.displayMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.primaryBrown, + ), + ), + const SizedBox(height: 8), + Text( + 'dari 100', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: AppTheme.grey600), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.7), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Text( + 'Interpretasi:', + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + interpretation['category']!, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: interpretation['color'] as Color, + ), + ), + const SizedBox(height: 4), + Text( + interpretation['description']!, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 16), + _buildScoreBreakdown(), + ], + ), + ); + } + + Widget _buildScoreBreakdown() { + return ExpansionTile( + title: const Text('Detail Perhitungan'), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Rumus SUS Score:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text('โ€ข Pertanyaan ganjil (1,3,5,7,9): (nilai - 1) ร— 2.5'), + const Text('โ€ข Pertanyaan genap (2,4,6,8,10): (5 - nilai) ร— 2.5'), + const Text('โ€ข Total: Jumlah semua skor'), + const SizedBox(height: 12), + const Text( + 'Interpretasi Skor:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + _buildScoreRange('90-100', 'Excellent', AppTheme.success), + _buildScoreRange('80-89', 'Good', Colors.lightGreen), + _buildScoreRange('70-79', 'OK', AppTheme.warning), + _buildScoreRange('60-69', 'Poor', Colors.orange), + _buildScoreRange('0-59', 'Awful', AppTheme.error), + ], + ), + ), + ], + ); + } + + Widget _buildScoreRange(String range, String category, Color color) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text('$range: $category'), + ], + ), + ); + } + + double _calculateSUSScore() { + double totalScore = 0; + + for (int i = 0; i < _responses.length; i++) { + if (i % 2 == 0) { + // Odd questions (1,3,5,7,9) - positive statements + totalScore += (_responses[i] - 1) * 2.5; + } else { + // Even questions (2,4,6,8,10) - negative statements + totalScore += (5 - _responses[i]) * 2.5; + } + } + + if (!_isCompleted) { + setState(() => _isCompleted = true); + } + + return totalScore; + } + + Map _interpretSUSScore(double score) { + if (score >= 90) { + return { + 'category': 'Excellent', + 'description': 'Aplikasi memiliki kegunaan yang sangat baik', + 'color': AppTheme.success, + }; + } else if (score >= 80) { + return { + 'category': 'Good', + 'description': 'Aplikasi memiliki kegunaan yang baik', + 'color': Colors.lightGreen, + }; + } else if (score >= 70) { + return { + 'category': 'OK', + 'description': 'Aplikasi memiliki kegunaan yang cukup', + 'color': AppTheme.warning, + }; + } else if (score >= 60) { + return { + 'category': 'Poor', + 'description': 'Aplikasi memiliki kegunaan yang kurang', + 'color': Colors.orange, + }; + } else { + return { + 'category': 'Awful', + 'description': 'Aplikasi memiliki kegunaan yang sangat kurang', + 'color': AppTheme.error, + }; + } + } +} diff --git a/lib/services/checkin_dataset.dart b/lib/services/checkin_dataset.dart new file mode 100644 index 0000000..aa9ce22 --- /dev/null +++ b/lib/services/checkin_dataset.dart @@ -0,0 +1,950 @@ +// ignore_for_file: lines_longer_than_80_chars +/// Dataset untuk check-in harian 3-halaman. +/// Setiap kategori menyimpan 50 pertanyaan yang dipilih berdasarkan hari dalam setahun. +library; + +class CheckinEntry { + final String question; + final List options; // selalu 4 opsi + const CheckinEntry(this.question, this.options); +} + +class CheckinDataset { + // --------------------------------------------------------------------------- + // MOOD (50 pertanyaan) โ€” opsi: Senang / Sedih / Kecewa / Marah + // --------------------------------------------------------------------------- + static const List mood = [ + CheckinEntry('Bagaimana perasaanmu saat ini?', [ + 'Lagi hepi banget! ๐Ÿ˜Š', + 'Lagi sedih, nih. ๐Ÿ˜ข', + 'Jujur, lagi kecewa. ๐Ÿ˜•', + 'Lagi emosi/marah! ๐Ÿ˜ค', + ]), + CheckinEntry('Kondisi hatimu lagi gimana?', [ + 'Cerah ceria! โ˜€๏ธ', + 'Mendung/galau. ๐ŸŒง๏ธ', + 'Lagi zonk/nyesek. ๐Ÿ˜ž', + 'Panas/mau ngamuk! ๐Ÿ”ฅ', + ]), + CheckinEntry('Apa kabar harimu sejauh ini?', [ + 'Seru dan menyenangkan! ๐ŸŽ‰', + 'Berat dan menyedihkan. ๐Ÿ˜”', + 'Kacau, gak sesuai mauku. ๐Ÿ˜ฉ', + 'Buruk! Bikin kesel terus. ๐Ÿ˜ ', + ]), + CheckinEntry('Butuh asupan mood seperti apa?', [ + 'Yang manis buat nambah hepi ๐Ÿฐ', + 'Yang bisa nemenin nangis ๐Ÿซ', + 'Yang bisa ngobatin sesal ๐Ÿต', + 'Yang dingin biar gak emosi โ„๏ธ', + ]), + CheckinEntry('Level energi mentalmu sekarang?', [ + 'Full banget, lagi semangat! โšก', + 'Drop, lagi pengen rebahan. ๐Ÿ›๏ธ', + 'Low, gara-gara gagal. ๐Ÿ“‰', + 'Meledak-ledak, lagi murka! ๐Ÿ’ฅ', + ]), + CheckinEntry('Warna apa yang mewakili moodmu?', [ + 'Kuning cerah (Ceria) ๐Ÿ’›', + 'Biru kelabu (Sendu) ๐Ÿฉต', + 'Abu-abu (Pudar/Loyo) ๐Ÿฉถ', + 'Merah menyala (Marah) โค๏ธโ€๐Ÿ”ฅ', + ]), + CheckinEntry('Apa yang sedang kamu rasakan?', [ + 'Sangat puas dan bersyukur ๐Ÿ™', + 'Hampa dan kesepian ๐Ÿ•ณ๏ธ', + 'Merasa dikhianati/pupus ๐Ÿ’”', + 'Dongkol dan muak! ๐Ÿ˜’', + ]), + CheckinEntry('Lagi pengen dimanjain rasa apa?', [ + 'Rasa bahagia yang manis! ๐Ÿฏ', + 'Rasa mellow yang tenang ๐ŸŒŠ', + 'Rasa yang bisa nenangin ๐ŸŒฟ', + 'Rasa yang bisa nahan emosi ๐ŸงŠ', + ]), + CheckinEntry('Vibes kamu hari ini cenderung ke arah?', [ + 'Positive Vibes! โœจ', + 'Sad Vibes... ๐ŸŒง๏ธ', + 'Gloomy/Zonk Vibes ๐Ÿ˜ถโ€๐ŸŒซ๏ธ', + 'Toxic/Angry Vibes! ๐Ÿ’ข', + ]), + CheckinEntry('Gimana senyummu hari ini?', [ + 'Lebar banget, lagi seneng! ๐Ÿ˜', + 'Hilang, lagi mau nangis ๐Ÿ˜ญ', + 'Tipis, lagi ngerasa gagal ๐Ÿ˜ถ', + 'Gak ada, lagi pengen marah! ๐Ÿ˜ก', + ]), + CheckinEntry('Mood kamu di kafe ini lagi apa?', [ + 'Mau ngerayain sesuatu! ๐ŸŽŠ', + 'Mau menenangkan diri ๐Ÿง˜', + 'Mau ngilangin nyesek ๐Ÿ˜ฎโ€๐Ÿ’จ', + 'Mau dinginin kepala ๐ŸงŠ', + ]), + CheckinEntry('Apa satu kata untuk jiwamu?', [ + 'Berbunga! ๐ŸŒธ', + 'Patah ๐Ÿ’”', + 'Pudar ๐Ÿชซ', + 'Membara (Emosi) ๐Ÿ”ฅ', + ]), + CheckinEntry('Baterai kebahagiaanmu berapa %?', [ + '100%, lagi hepi maksimal! ๐Ÿ”‹', + '10%, lagi sedih banget ๐Ÿชซ', + '30%, lagi ngerasa kecewa ๐Ÿ“‰', + '0%, lagi emosi tingkat dewa! ๐Ÿ’ข', + ]), + CheckinEntry('Cuaca batinmu sedang...', [ + 'Terang benderang โ˜€๏ธ', + 'Hujan rintik-rintik ๐ŸŒง๏ธ', + 'Berawan mendung โ›…', + 'Badai petir! โ›ˆ๏ธ', + ]), + CheckinEntry('Lagi ngerasa dunia berpihak padamu?', [ + 'Iya, lagi hoki dan hepi! ๐Ÿ€', + 'Nggak, lagi sedih terus ๐Ÿ˜”', + 'Nggak, lagi dikecewain ๐Ÿ˜ž', + 'Nggak, semuanya bikin marah! ๐Ÿ˜ค', + ]), + CheckinEntry('Gimana ritme jantungmu?', [ + 'Menggebu karena senang! ๐Ÿ’“', + 'Lambat karena lemas ๐Ÿ’™', + 'Berhenti sesaat (syok/kecewa) ๐Ÿ’”', + 'Berdetak kencang (marah)! ๐Ÿ’ข', + ]), + CheckinEntry('Lagi pengen self-reward karena apa?', [ + 'Karena goals tercapai! ๐Ÿ†', + 'Karena lagi butuh hiburan ๐ŸŽญ', + 'Karena lagi gagal/sedih ๐Ÿ˜”', + 'Karena capek marah-marah ๐Ÿ˜ฎโ€๐Ÿ’จ', + ]), + CheckinEntry('Apa yang mendominasi pikiranmu?', [ + 'Hal-hal indah & seru ๐ŸŒˆ', + 'Kenangan yang menyedihkan ๐Ÿ˜ข', + 'Harapan yang gak tercapai ๐Ÿ˜ž', + 'Masalah yang bikin emosi ๐Ÿ˜ ', + ]), + CheckinEntry('Gimana kondisi semangatmu?', [ + 'Membara dan positif! ๐Ÿ”ฅ', + 'Layu dan tidak bergairah ๐Ÿฅ€', + 'Patah dan tawar ๐Ÿ’”', + 'Meledak dan tajam! ๐Ÿ’ฅ', + ]), + CheckinEntry('Lagi butuh teman seperti apa?', [ + 'Teman buat ketawa bareng ๐Ÿ˜‚', + 'Teman buat curhat sedih ๐Ÿค—', + 'Teman buat bagi kecewa ๐Ÿ’ฌ', + 'Jangan ditemenin, lagi marah! ๐Ÿšซ', + ]), + CheckinEntry('Lagi merasa hidup ini indah?', [ + 'Sangat indah & sempurna! ๐ŸŒบ', + 'Terasa kelabu & sepi ๐Ÿฉถ', + 'Terasa pahit & hambar ๐Ÿ˜•', + 'Terasa kacau & berisik! ๐Ÿ˜ค', + ]), + CheckinEntry('Gimana energi yang kamu bawa?', [ + 'Energi positif & ceria โšก', + 'Energi rendah & lemas ๐Ÿ˜ด', + 'Energi drop karena gagal ๐Ÿ“‰', + 'Energi panas & emosi ๐Ÿ”ฅ', + ]), + CheckinEntry('Apa yang sedang bergejolak?', [ + 'Rasa syukur yang besar ๐Ÿ™', + 'Rasa rindu yang perih ๐Ÿ’™', + 'Rasa sesal yang dalam ๐Ÿ˜ž', + 'Rasa benci yang meluap ๐Ÿ˜ก', + ]), + CheckinEntry('Bagaimana kondisi "baterai"-mu?', [ + 'Full 100%, siap gas! ๐Ÿ”‹', + 'Low batt, pengen nangis ๐Ÿชซ', + 'Error, ekspektasi zonk ๐Ÿ’”', + 'Overheat, lagi meledak! ๐Ÿ”ฅ', + ]), + CheckinEntry('Butuh menu yang rasanya...', [ + 'Manis & menyegarkan! ๐Ÿฐ', + 'Hangat & menenangkan ๐Ÿต', + 'Netral & ngobatin nyesek ๐Ÿซ–', + 'Dingin & nahan emosi ๐ŸงŠ', + ]), + CheckinEntry('Apa yang kamu rasakan sekarang?', [ + 'Puas banget sama hari ini ๐Ÿ˜Š', + 'Hampa, kayak ada yang hilang ๐Ÿ•ณ๏ธ', + 'Patah hati sama keadaan ๐Ÿ’”', + 'Murka, semuanya salah! ๐Ÿ˜ค', + ]), + CheckinEntry('Mood-mu lagi di posisi mana?', [ + 'Posisi "On Top"! ๐Ÿ†', + 'Posisi "Down" banget ๐Ÿ“‰', + 'Posisi "Zonk" & gagal ๐Ÿ˜”', + 'Posisi "Senggol Bacok"! ๐Ÿ’ข', + ]), + CheckinEntry('Lagi ingin meluapkan apa?', [ + 'Tawa dan kegembiraan ๐Ÿ˜‚', + 'Air mata kesedihan ๐Ÿ˜ข', + 'Keluh kesah kekecewaan ๐Ÿ˜ฎโ€๐Ÿ’จ', + 'Amarah yang tertahan ๐Ÿ˜ ', + ]), + CheckinEntry('Seberapa besar keinginan senyum?', [ + '100%, gak berhenti senyum ๐Ÿ˜', + '0%, lagi mau mewek ๐Ÿ˜ญ', + '20%, lagi gak mood ๐Ÿ˜ถ', + '-100%, lagi mau ngamuk! ๐Ÿ˜ก', + ]), + CheckinEntry('Apa warna duniamu saat ini?', [ + 'Pelangi yang cerah ๐ŸŒˆ', + 'Biru yang sendu ๐Ÿ’™', + 'Abu-abu yang pudar ๐Ÿฉถ', + 'Merah yang membara โค๏ธโ€๐Ÿ”ฅ', + ]), + CheckinEntry('Gimana kabar semangatmu?', [ + 'Lagi semangat-semangatnya! โšก', + 'Lagi layu & tidak berdaya ๐Ÿฅ€', + 'Lagi patah & pupus ๐Ÿ’”', + 'Lagi tajam & berbahaya ๐Ÿ’ข', + ]), + CheckinEntry('Lagi ngerasa dihargai atau...', [ + 'Dihargai banget, hepi! ๐Ÿ˜Š', + 'Diabaikan, sedih rasanya ๐Ÿ˜”', + 'Disepelekan, kecewa deh ๐Ÿ˜ž', + 'Dihina, jadi emosi! ๐Ÿ˜ค', + ]), + CheckinEntry('Suasana batinmu cenderung...', [ + 'Berbunga-bunga ๐ŸŒธ', + 'Terluka & perih ๐Ÿ’”', + 'Tawar & nyesek ๐ŸŒซ๏ธ', + 'Panas & gerah ๐Ÿ”ฅ', + ]), + CheckinEntry('Lagi butuh asupan jiwa ya?', [ + 'Iya, buat ngerayain hepi ๐ŸŽ‰', + 'Iya, buat nyembuhin sedih ๐Ÿง˜', + 'Iya, buat ngapus kecewa ๐Ÿ’†', + 'Iya, buat ngeredam marah โ„๏ธ', + ]), + CheckinEntry('Gimana progres harimu?', [ + 'Lancar jaya & asik! ๐Ÿš€', + 'Macet & bikin galau ๐ŸŒง๏ธ', + 'Berantakan, zonk banget ๐Ÿ’”', + 'Rusak! Bikin naik pitam ๐Ÿ˜ ', + ]), + CheckinEntry('Perasaan dominan saat masuk?', [ + 'Excited & tidak sabar! โœจ', + 'Melankolis & pengen sepi ๐ŸŒ™', + 'Pesimis & kurang puas ๐Ÿ˜ถ', + 'Agresif & lagi kesel ๐Ÿ’ข', + ]), + CheckinEntry('Lagi ngerasa beruntung?', [ + 'Banget, lagi hoki terus! ๐Ÿ€', + 'Enggak, lagi sial & sedih ๐Ÿ˜”', + 'Enggak, lagi dapet zonk ๐Ÿ˜ž', + 'Enggak, malah bikin emosi! ๐Ÿ˜ค', + ]), + CheckinEntry('Kondisi pikiranmu sekarang?', [ + 'Plong & tanpa beban ๐Ÿ˜Œ', + 'Penuh beban & kesedihan ๐Ÿ˜ข', + 'Penuh sesal & kecewa ๐Ÿ˜ž', + 'Penuh amarah & dendam ๐Ÿ˜ ', + ]), + CheckinEntry('Kamu lagi merasa "Cukup"?', [ + 'Lebih dari cukup, hepi! ๐Ÿ˜Š', + 'Kurang, merasa hampa ๐Ÿ•ณ๏ธ', + 'Gak cukup, kecewa deh ๐Ÿ˜ž', + 'Gak peduli, lagi emosi! ๐Ÿ’ข', + ]), + CheckinEntry('Butuh teman ngobrol?', [ + 'Mau dong, lagi hepi nih! ๐Ÿ˜„', + 'Nanti aja, lagi galau ๐Ÿ˜ถ', + 'Boleh, lagi nyesek nih ๐Ÿ’ฌ', + 'Gak usah, lagi sensi! ๐Ÿšซ', + ]), + CheckinEntry('Seberapa cerah duniamu?', [ + 'Terang benderang! โ˜€๏ธ', + 'Gelap & mencekam ๐ŸŒ‘', + 'Remang-remang/loyo ๐ŸŒซ๏ธ', + 'Membara/panas ๐Ÿ”ฅ', + ]), + CheckinEntry('Lagi pengen "Self-Healing"?', [ + 'Gak perlu, lagi hepi kok ๐Ÿ˜Š', + 'Banget, lagi terluka ๐Ÿ’”', + 'Perlu, lagi kecewa berat ๐Ÿ˜”', + 'Butuh, biar gak ngamuk โ„๏ธ', + ]), + CheckinEntry('Gimana detak jantungmu?', [ + 'Berdegup senang! ๐Ÿ’“', + 'Lemah & tak berdaya ๐Ÿ’™', + 'Berhenti (syok/kecewa) ๐Ÿ’”', + 'Berdebar kencang (marah) ๐Ÿ’ข', + ]), + CheckinEntry('Rasa apa yang paling cocok?', [ + 'Manis buah yang segar ๐Ÿ“', + 'Cokelat hangat yang nyaman ๐Ÿซ', + 'Kopi pahit yang jujur โ˜•', + 'Soda dingin yang tajam ๐Ÿฅค', + ]), + CheckinEntry('Lagi merasa "I\'m Okay"?', [ + 'Very Okay! Hepi banget ๐Ÿ˜', + 'Not Okay, lagi sedih ๐Ÿ˜ข', + 'Not Okay, lagi zonk ๐Ÿ˜ž', + 'Not Okay, lagi emosi! ๐Ÿ˜ค', + ]), + CheckinEntry('Apa getaran (vibes) hari ini?', [ + 'Joyful & Fun! ๐ŸŽ‰', + 'Sad & Lonely ๐Ÿ’”', + 'Disappointed & Failed ๐Ÿ˜”', + 'Angry & Annoyed ๐Ÿ˜ค', + ]), + CheckinEntry('Ingin memanjakan diri dengan?', [ + 'Sesuatu yang seru! ๐ŸŽŠ', + 'Sesuatu yang tenang ๐Ÿง˜', + 'Sesuatu yang beda (nyesek) ๐Ÿ˜ถ', + 'Sesuatu yang keras/kuat ๐Ÿ’ช', + ]), + CheckinEntry('Mood level hari ini?', [ + 'Sangat Senang ๐Ÿ˜„', + 'Sangat Sedih ๐Ÿ˜ข', + 'Sangat Kecewa ๐Ÿ˜ž', + 'Sangat Marah ๐Ÿ˜ก', + ]), + CheckinEntry('Lagi merasa dicintai?', [ + 'Banget, makanya hepi! ๐Ÿ’•', + 'Nggak, ngerasa ditinggal ๐Ÿ˜ข', + 'Nggak, ngerasa dibohongi ๐Ÿ˜”', + 'Nggak, malah dikhianati! ๐Ÿ’ข', + ]), + CheckinEntry('Jadi, pesananmu untuk mood?', [ + 'Yang bikin makin ceria! ๐ŸŒˆ', + 'Yang bisa nenangin galau ๐ŸŒฟ', + 'Yang bisa ngobatin kecewa ๐Ÿ’†', + 'Yang bisa nurunin emosi โ„๏ธ', + ]), + ]; + + // --------------------------------------------------------------------------- + // CUACA (50 pertanyaan) โ€” opsi: Hujan / Cerah / Dingin / Panas + // --------------------------------------------------------------------------- + static const List cuaca = [ + CheckinEntry('Bagaimana cuaca di luar sana sekarang?', [ + 'Lagi hujan deras nih ๐ŸŒง๏ธ', + 'Cerah benderang โ˜€๏ธ', + 'Dingin banget, adem ๐ŸŒฌ๏ธ', + 'Panasnya minta ampun! ๐Ÿ”ฅ', + ]), + CheckinEntry('Kondisi langit di sekitarmu gimana?', [ + 'Lagi mendung & hujan โ›ˆ๏ธ', + 'Biru bersih & cerah ๐Ÿ’™', + 'Kelabu & terasa dingin ๐ŸŒซ๏ธ', + 'Silau & panas menyengat โ˜€๏ธ', + ]), + CheckinEntry('Apa yang kamu lihat dari jendela kafe?', [ + 'Air hujan turun terus ๐Ÿ’ง', + 'Sinar matahari terik ๐ŸŒž', + 'Kabut/angin dingin ๐ŸŒฌ๏ธ', + 'Aspal menguap karena panas ๐ŸŒก๏ธ', + ]), + CheckinEntry('Butuh menu yang sesuai cuaca apa?', [ + 'Yang pas buat musim hujan ๐ŸŒง๏ธ', + 'Yang cocok buat hari cerah โ˜€๏ธ', + 'Yang nemenin hawa dingin โ„๏ธ', + 'Yang nurunin suhu panas ๐ŸงŠ', + ]), + CheckinEntry('Gimana "vibes" cuaca hari ini?', [ + 'Wet & Rainy Vibes ๐ŸŒง๏ธ', + 'Sunny & Bright Vibes โ˜€๏ธ', + 'Chilly & Cool Vibes ๐ŸŒฌ๏ธ', + 'Hot & Burning Vibes ๐Ÿ”ฅ', + ]), + CheckinEntry('Udara di tempatmu sekarang terasa...', [ + 'Basah dan lembap ๐Ÿ’ง', + 'Hangat dan terang ๐ŸŒค๏ธ', + 'Sejuk dan menggigil โ„๏ธ', + 'Kering dan gerah ๐ŸŒก๏ธ', + ]), + CheckinEntry('Kalau melirik ke luar, lagi ada apa?', [ + 'Ada rintik-rintik hujan ๐ŸŒง๏ธ', + 'Ada matahari yang kuat โ˜€๏ธ', + 'Ada angin sepoi dingin ๐ŸŒฌ๏ธ', + 'Ada hawa panas bumi ๐Ÿ”ฅ', + ]), + CheckinEntry('Suasana di luar sedang mendukung...', [ + 'Tidur karena hujan ๐Ÿ˜ด', + 'Jalan-jalan karena cerah ๐Ÿšถ', + 'Pakai jaket karena dingin ๐Ÿงฅ', + 'Nyari AC karena panas โ„๏ธ', + ]), + CheckinEntry('Lagi musim apa di daerahmu hari ini?', [ + 'Musim hujan/basah ๐ŸŒง๏ธ', + 'Musim kemarau/terik โ˜€๏ธ', + 'Musim dingin/berangin ๐ŸŒฌ๏ธ', + 'Musim panas/gerah ๐Ÿ”ฅ', + ]), + CheckinEntry('Apa warna dominan langit sekarang?', [ + 'Kelabu gelap (Hujan) โ›ˆ๏ธ', + 'Biru muda (Cerah) ๐Ÿ’™', + 'Putih pucat (Dingin) ๐ŸŒซ๏ธ', + 'Kuning oranye (Panas) ๐ŸŒ…', + ]), + CheckinEntry('Gimana tekanan udara yang kamu rasain?', [ + 'Berat karena mau hujan โ›ˆ๏ธ', + 'Ringan dan cerah ๐ŸŒค๏ธ', + 'Segar dan dingin โ„๏ธ', + 'Pengap dan panas ๐Ÿ”ฅ', + ]), + CheckinEntry('Seberapa butuh kamu menu "penyelamat"?', [ + 'Butuh penghangat hujan โ˜•', + 'Butuh penyegar cerah ๐Ÿน', + 'Butuh penenang dingin ๐Ÿต', + 'Butuh pendingin panas ๐ŸงŠ', + ]), + CheckinEntry('Apa kata pertama yang muncul pas keluar?', [ + '"Yah, hujan..." ๐Ÿ˜ฎ', + '"Wah, cerah!" ๐Ÿ˜„', + '"Duh, dingin." ๐Ÿฅถ', + '"Waduh, panas!" ๐Ÿฅต', + ]), + CheckinEntry('Cuaca sekarang bikin kamu ingin...', [ + 'Ngopi hangat (Hujan) โ˜•', + 'Minum jus (Cerah) ๐ŸŠ', + 'Cokelat panas (Dingin) ๐Ÿซ', + 'Es kopi banyak (Panas) ๐ŸงŠ', + ]), + CheckinEntry('Gimana intensitas matahari saat ini?', [ + 'Tertutup awan hujan โ›ˆ๏ธ', + 'Sangat kuat & cerah โ˜€๏ธ', + 'Lemah & terasa dingin ๐ŸŒฅ๏ธ', + 'Sangat menyengat/panas ๐ŸŒก๏ธ', + ]), + CheckinEntry('Pakaian yang kamu pakai cocok buat...', [ + 'Neduh karena hujan ๐ŸŒ‚', + 'OOTD karena cerah ๐Ÿ‘—', + 'Hangat-hangatan (Dingin) ๐Ÿงฅ', + 'Baju tipis karena panas ๐Ÿ‘•', + ]), + CheckinEntry('Kondisi aspal di luar sana lagi...', [ + 'Basah kuyup ๐Ÿ’ฆ', + 'Kering kerontang โ˜€๏ธ', + 'Lembap dingin โ„๏ธ', + 'Panas berasap ๐Ÿ”ฅ', + ]), + CheckinEntry('Kamu merasa butuh menu yang...', [ + 'Menghangatkan (Hujan) ๐Ÿ”ฅ', + 'Menyegarkan (Cerah) ๐Ÿ’ง', + 'Menenangkan (Dingin) ๐ŸŒฟ', + 'Mendinginkan (Panas) ๐ŸงŠ', + ]), + CheckinEntry('Seberapa ekstrem cuaca sekarang?', [ + 'Hujan gak berhenti ๐ŸŒง๏ธ', + 'Cerah nggak ada awan โ˜€๏ธ', + 'Dingin menusuk tulang ๐Ÿฅถ', + 'Panas membakar kulit ๐Ÿฅต', + ]), + CheckinEntry('Apa "backsound" alam sekarang?', [ + 'Suara gemercik hujan ๐ŸŽถ', + 'Suara burung (Cerah) ๐Ÿฆ', + 'Suara angin (Dingin) ๐ŸŒฌ๏ธ', + 'Suara kipas/AC (Panas) ๐Ÿ’จ', + ]), + CheckinEntry('Lagi terjebak di kondisi cuaca apa?', [ + 'Terjebak hujan ๐ŸŒง๏ธ', + 'Terjebak terik matahari โ˜€๏ธ', + 'Terjebak suhu dingin ๐Ÿฅถ', + 'Terjebak hawa gerah ๐Ÿฅต', + ]), + CheckinEntry('Apa yang paling terasa di kulitmu?', [ + 'Air dan kelembapan ๐Ÿ’ง', + 'Sinar matahari hangat โ˜€๏ธ', + 'Angin yang sejuk ๐ŸŒฌ๏ธ', + 'Keringat karena panas ๐Ÿ’ฆ', + ]), + CheckinEntry('Gimana ramalan cuaca versi matamu?', [ + 'Bakal hujan lama ๐ŸŒง๏ธ', + 'Bakal cerah seharian โ˜€๏ธ', + 'Bakal tetap dingin โ„๏ธ', + 'Bakal tambah panas ๐Ÿ”ฅ', + ]), + CheckinEntry('Tingkat kebutuhan es vs hangat?', [ + '100% Hangat (Hujan) โ˜•', + '50/50 (Cerah) โ˜ฏ๏ธ', + '80% Hangat (Dingin) ๐Ÿต', + '100% Es (Panas) ๐ŸงŠ', + ]), + CheckinEntry('Apa gangguan terbesar di jalan tadi?', [ + 'Jalanan banjir/becek ๐ŸŒŠ', + 'Silau matahari ๐Ÿ˜Ž', + 'Angin kencang dingin ๐ŸŒฌ๏ธ', + 'Debu dan panas ๐ŸŒซ๏ธ', + ]), + CheckinEntry('Kondisi jendela kafe sekarang...', [ + 'Berembun karena hujan ๐Ÿ’ง', + 'Tembus cahaya cerah โ˜€๏ธ', + 'Dingin saat disentuh โ„๏ธ', + 'Terasa hangat kena panas ๐Ÿ”ฅ', + ]), + CheckinEntry('Cuaca ini bikin kamu mikir tentang...', [ + 'Selimut (Hujan) ๐Ÿ›๏ธ', + 'Pantai (Cerah) ๐Ÿ–๏ธ', + 'Pelukan (Dingin) ๐Ÿค—', + 'Es krim (Panas) ๐Ÿฆ', + ]), + CheckinEntry('Suara petir atau suara burung?', [ + 'Petir (Hujan) โšก', + 'Burung berkicau (Cerah) ๐Ÿฆ', + 'Angin berdesir (Dingin) ๐ŸŒฌ๏ธ', + 'Jangkrik siang (Panas) ๐Ÿฆ—', + ]), + CheckinEntry('Kamu sampai sini dalam keadaan...', [ + 'Sedikit basah (Hujan) ๐Ÿ’ฆ', + 'Rapi karena cerah ๐Ÿ˜Š', + 'Menggigil (Dingin) ๐Ÿฅถ', + 'Berkeringat (Panas) ๐Ÿฅต', + ]), + CheckinEntry('Seberapa terang duniamu saat ini?', [ + 'Redup (Hujan) ๐ŸŒ‘', + 'Sangat terang (Cerah) โ˜€๏ธ', + 'Abu-abu (Dingin) ๐Ÿฉถ', + 'Menyilaukan (Panas) ๐ŸŒ…', + ]), + CheckinEntry('Cuaca sekarang paling enak buat...', [ + 'Melamun (Hujan) ๐Ÿ˜ถ', + 'Ngobrol (Cerah) ๐Ÿ’ฌ', + 'Baca buku (Dingin) ๐Ÿ“–', + 'Minum es (Panas) ๐ŸงŠ', + ]), + CheckinEntry('Apa warna payung yang kamu butuh?', [ + 'Payung hujan ๐ŸŒ‚', + 'Payung peneduh terik โ˜‚๏ธ', + 'Gak butuh, butuh jaket ๐Ÿงฅ', + 'Gak butuh, butuh kipas ๐ŸŒ€', + ]), + CheckinEntry('Hawa ruangan dibanding luar gimana?', [ + 'Luar lebih basah ๐Ÿ’ง', + 'Luar lebih cerah โ˜€๏ธ', + 'Luar jauh lebih dingin ๐Ÿฅถ', + 'Luar jauh lebih panas ๐Ÿฅต', + ]), + CheckinEntry('Gimana "mood" alam semesta hari ini?', [ + 'Lagi nangis (Hujan) ๐Ÿ˜ข', + 'Lagi senyum (Cerah) ๐Ÿ˜Š', + 'Lagi diem (Dingin) ๐Ÿ˜ถ', + 'Lagi marah (Panas) ๐Ÿ˜ ', + ]), + CheckinEntry('Lagi butuh asupan yang suhunya...', [ + 'Panas/Mendidih ๐Ÿ”ฅ', + 'Dingin/Segar โ„๏ธ', + 'Hangat/Nyaman โ™จ๏ธ', + 'Beku/Es banyak ๐ŸงŠ', + ]), + CheckinEntry('Kondisi cuaca ini termasuk kategori...', [ + 'Syahdu (Hujan) ๐ŸŽถ', + 'Ceria (Cerah) ๐ŸŒˆ', + 'Nyaman (Dingin) ๐ŸŒฟ', + 'Menyiksa (Panas) ๐Ÿ˜ซ', + ]), + CheckinEntry('Apa yang paling menonjol di luar?', [ + 'Awan hitam โ›ˆ๏ธ', + 'Langit biru โ˜€๏ธ', + 'Angin sejuk ๐ŸŒฌ๏ธ', + 'Uap panas ๐ŸŒก๏ธ', + ]), + CheckinEntry('Kamu pengen menu yang bisa...', [ + 'Ngusir kedinginan hujan โ˜•', + 'Nambah semangat cerah ๐Ÿน', + 'Nemenin ademnya hari ๐Ÿต', + 'Ngusir gerah yang parah ๐ŸงŠ', + ]), + CheckinEntry('Seberapa "ekstrim" suhu di luar?', [ + 'Basah maksimal ๐ŸŒŠ', + 'Terik maksimal โ˜€๏ธ', + 'Dingin maksimal ๐Ÿฅถ', + 'Panas maksimal ๐Ÿฅต', + ]), + CheckinEntry('Apa elemen alam yang dominan?', [ + 'Air (Hujan) ๐Ÿ’ง', + 'Cahaya (Cerah) โ˜€๏ธ', + 'Angin (Dingin) ๐ŸŒฌ๏ธ', + 'Api/Hawa (Panas) ๐Ÿ”ฅ', + ]), + CheckinEntry('Gimana kondisi pandangan mata?', [ + 'Kabur karena hujan ๐ŸŒง๏ธ', + 'Jelas dan cerah โ˜€๏ธ', + 'Sayu karena dingin ๐Ÿฅถ', + 'Berkunang karena panas ๐Ÿฅต', + ]), + CheckinEntry('Butuh menu yang bikin kamu...', [ + 'Hangat di dalam โ˜•', + 'Segar di mata ๐Ÿƒ', + 'Tenang di hati ๐ŸŒฟ', + 'Adem di tenggorokan ๐ŸงŠ', + ]), + CheckinEntry('Lagi ada fenomena apa di luar?', [ + 'Hujan petir โšก', + 'Panas terik ๐ŸŒž', + 'Angin kencang ๐ŸŒฌ๏ธ', + 'Kemarau panjang ๐Ÿœ๏ธ', + ]), + CheckinEntry('Cuaca ini paling pas ditemani...', [ + 'Kopi susu hangat โ˜•', + 'Lemon tea dingin ๐Ÿ‹', + 'Matcha latte hangat ๐Ÿต', + 'Es cokelat pekat ๐ŸงŠ', + ]), + CheckinEntry('Gimana tingkat kelembapan udara?', [ + 'Tinggi banget (Hujan) ๐Ÿ’ฆ', + 'Pas/Kering (Cerah) โ˜€๏ธ', + 'Rendah/Dingin โ„๏ธ', + 'Sangat pengap (Panas) ๐Ÿฅต', + ]), + CheckinEntry('Kamu masuk kafe buat cari...', [ + 'Tempat berteduh hujan ๐ŸŒ‚', + 'Tempat nongkrong cerah ๐ŸŒž', + 'Tempat hangat โ˜•', + 'Tempat ngadem/AC โ„๏ธ', + ]), + CheckinEntry('Apa yang kamu rasakan di ubun-ubun?', [ + 'Tetesan air hujan ๐Ÿ’ง', + 'Hangat matahari โ˜€๏ธ', + 'Angin lewat ๐ŸŒฌ๏ธ', + 'Keringat mengalir ๐Ÿฅต', + ]), + CheckinEntry('Cuaca hari ini bikin malas atau rajin?', [ + 'Malas karena hujan ๐Ÿ˜ด', + 'Rajin karena cerah ๐Ÿ’ช', + 'Malas karena adem ๐Ÿ˜ช', + 'Rajin cari yang dingin โ„๏ธ', + ]), + CheckinEntry('Gimana kondisi "semesta" kafe ini?', [ + 'Penyelamat dari hujan ๐ŸŒ‚', + 'Penambah keceriaan โ˜€๏ธ', + 'Pelindung dari dingin ๐Ÿงฅ', + 'Oase dari panas โ„๏ธ', + ]), + CheckinEntry('Jadi, kesimpulannya cuaca lagi...', [ + 'Hujan, butuh hangat ๐ŸŒง๏ธ', + 'Cerah, butuh segar โ˜€๏ธ', + 'Dingin, butuh nyaman โ„๏ธ', + 'Panas, butuh yang es! ๐ŸงŠ', + ]), + ]; + + // --------------------------------------------------------------------------- + // SUASANA (50 pertanyaan) โ€” opsi: Sendiri / Berdua / Rame-rame / Biasa + // --------------------------------------------------------------------------- + static const List suasana = [ + CheckinEntry('Kamu ke sini bareng siapa hari ini?', [ + 'Lagi sendirian aja ๐Ÿ™‹', + 'Berdua sama teman/pacar ๐Ÿ‘ซ', + 'Rame-rame sama geng ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', + 'Rombongan tapi santai ๐Ÿšถ', + ]), + CheckinEntry('Siapa yang nemenin kamu memesan?', [ + 'Cuma aku dan pikiranku ๐Ÿ’ญ', + 'Satu orang favoritku ๐Ÿ’•', + 'Pasukan lengkap! ๐ŸŽ‰', + 'Teman-teman kantor/tugas ๐Ÿ’ผ', + ]), + CheckinEntry('Lagi pengen porsi makan buat berapa orang?', [ + 'Buat aku sendiri aja ๐Ÿ™‹', + 'Porsi sharing berdua ๐Ÿ‘ซ', + 'Porsi jumbo buat barengan ๐ŸŽŠ', + 'Porsi standar masing-masing ๐Ÿช‘', + ]), + CheckinEntry('Suasana meja yang kamu butuhin...', [ + 'Pojokan kecil buat sendiri ๐Ÿชด', + 'Meja kecil buat berdua ๐Ÿ•ฏ๏ธ', + 'Meja panjang buat grup ๐ŸŽ‰', + 'Meja mana aja yang kosong ๐Ÿช‘', + ]), + CheckinEntry('Kamu lagi dalam mode apa sekarang?', [ + 'Mode introvert/Me-time ๐ŸŽง', + 'Mode deep talk berdua ๐Ÿ’ฌ', + 'Mode party/nongkrong ๐ŸŽŠ', + 'Mode biasa, cuma singgah ๐Ÿšถ', + ]), + CheckinEntry('Seberapa banyak kursi yang kamu pesan?', [ + 'Satu kursi cukup ๐Ÿ™‹', + 'Dua kursi berhadapan ๐Ÿ‘ซ', + 'Kursi banyak, mau kumpul ๐ŸŽ‰', + 'Kursi secukupnya aja ๐Ÿช‘', + ]), + CheckinEntry('Datang ke kafe ini dalam rangka...', [ + 'Menikmati waktu sendiri ๐ŸŽง', + 'Kencan/Ngobrol berdua ๐Ÿ’•', + 'Rayain sesuatu barengan ๐ŸŽŠ', + 'Ngumpul santai biasa ๐Ÿ˜Š', + ]), + CheckinEntry('Apa kamu butuh menu "Sharing"?', [ + 'Gak perlu, buat sendiri ๐Ÿ™‹', + 'Boleh, buat bagi berdua ๐Ÿ‘ซ', + 'Wajib! Biar hemat bareng ๐ŸŽ‰', + 'Liat nanti, masing-masing ๐Ÿช‘', + ]), + CheckinEntry('Lagi pengen diganggu atau tidak?', [ + 'Jangan, lagi mau sendiri ๐Ÿšซ', + 'Cuma mau fokus berdua ๐Ÿ‘ซ', + 'Bebas, lagi seru-seruan ๐ŸŽŠ', + 'Biasa aja, gak masalah ๐Ÿ˜Š', + ]), + CheckinEntry('Siapa "partner" ngopimu kali ini?', [ + 'Gak ada, lagi mandiri ๐Ÿ™‹', + 'Bestie/Pasangan ๐Ÿ’•', + 'Teman sekelas/kerja ๐Ÿ‘ฅ', + 'Siapa aja yang lagi ada ๐Ÿšถ', + ]), + CheckinEntry('Kamu ngerasa meja ini bakal...', [ + 'Tenang dan sepi ๐ŸŒฟ', + 'Penuh dengan obrolan ๐Ÿ’ฌ', + 'Berisik dan seru ๐ŸŽ‰', + 'Kondusif dan normal ๐Ÿ˜Š', + ]), + CheckinEntry('Seberapa besar "space" yang kamu cari?', [ + 'Minimalis buat sendiri ๐Ÿ™‹', + 'Pas buat berdua ๐Ÿ‘ซ', + 'Luas buat grup besar ๐ŸŽŠ', + 'Yang penting bisa duduk ๐Ÿช‘', + ]), + CheckinEntry('Lagi mau fokus ngerjain sesuatu?', [ + 'Iya, sendirian biar fokus ๐Ÿ’ป', + 'Iya, nugas bareng teman ๐Ÿ‘ซ', + 'Enggak, lagi mau main ๐ŸŽ‰', + 'Iya, tapi sambil ngobrol ๐Ÿ’ฌ', + ]), + CheckinEntry('Kamu pesan menu ini untuk...', [ + 'Dinikmati secara privat ๐Ÿ™‹', + 'Dimakan bareng pasangan ๐Ÿ’•', + 'Dikeroyok bareng-bareng ๐ŸŽŠ', + 'Dimakan santai aja ๐Ÿ˜Š', + ]), + CheckinEntry('Kondisi "sosial" kamu saat ini...', [ + 'Lagi pengen menyepi ๐ŸŒฟ', + 'Lagi pengen berduaan ๐Ÿ’•', + 'Lagi pengen heboh ๐ŸŽ‰', + 'Lagi pengen biasa aja ๐Ÿ˜Š', + ]), + CheckinEntry('Apa kamu butuh privasi lebih?', [ + 'Banget, mau sendiri ๐Ÿ™‹', + 'Lumayan, mau ngobrol ๐Ÿ‘ซ', + 'Enggak, ramein aja! ๐ŸŽŠ', + 'Biasa, gak terlalu mikirin ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu datang dengan kendaraan apa?', [ + 'Sendiri (Motor/Ojol) ๐Ÿ๏ธ', + 'Berboncengan/Satu mobil ๐Ÿš—', + 'Konvoi/Banyak kendaraan ๐ŸšŒ', + 'Masing-masing sampai sini ๐Ÿšถ', + ]), + CheckinEntry('Gimana intensitas obrolan di mejamu?', [ + 'Hening/Gak ada suara ๐Ÿคซ', + 'Intens dan dalam ๐Ÿ’ฌ', + 'Kencang dan tertawa ๐ŸŽ‰', + 'Sedang dan sewajarnya ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu pengen menu yang sifatnya...', [ + 'Personal (Individual) ๐Ÿ™‹', + 'Sweet (Couple vibes) ๐Ÿ’•', + 'Fun (Party vibes) ๐ŸŽŠ', + 'Casual (Neutral vibes) ๐Ÿ˜Š', + ]), + CheckinEntry('Apa kamu bawa "pasukan"?', [ + 'Pasukan satu orang (aku) ๐Ÿ™‹', + 'Duo maut! ๐Ÿ‘ซ', + 'Satu batalyon! ๐ŸŽ‰', + 'Beberapa orang aja ๐Ÿ‘ฅ', + ]), + CheckinEntry('Lagi mau pamer foto (OOTD) bareng?', [ + 'Foto sendiri aja ๐Ÿคณ', + 'Foto berdua estetik ๐Ÿ“ธ', + 'Foto grup/reuni ๐ŸŽŠ', + 'Foto menu aja, gak foto ๐Ÿ“ท', + ]), + CheckinEntry('Kamu butuh rekomendasi menu untuk...', [ + 'Satu orang (Single) ๐Ÿ™‹', + 'Dua orang (Duo) ๐Ÿ‘ซ', + 'Banyak orang (Family/Squad) ๐ŸŽŠ', + 'Siapa aja yang mau pesan ๐Ÿ˜Š', + ]), + CheckinEntry('Suasana hatimu dipengaruhi oleh...', [ + 'Diriku sendiri ๐Ÿ™‹', + 'Orang di depanku ๐Ÿ’•', + 'Orang-orang di sekitarku ๐Ÿ‘ฅ', + 'Keadaan kafe secara umum ๐Ÿ˜Š', + ]), + CheckinEntry('Seberapa ramai mejamu nanti?', [ + 'Gak ramai sama sekali ๐Ÿคซ', + 'Ramai suara kami berdua ๐Ÿ’ฌ', + 'Sangat ramai dan heboh ๐ŸŽ‰', + 'Ramai yang terkendali ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu ngerasa bakal lama di sini?', [ + 'Lama karena nugas sendiri ๐Ÿ’ป', + 'Lama karena ngobrol ๐Ÿ’ฌ', + 'Lama karena nongkrong ๐ŸŽŠ', + 'Sebentar, cuma mampir ๐Ÿšถ', + ]), + CheckinEntry('Apa kamu butuh stopkontak banyak?', [ + 'Satu cukup buat laptopku ๐Ÿ”Œ', + 'Dua buat hp kami ๐Ÿ”‹', + 'Banyak buat bareng-bareng ๐ŸŽ‰', + 'Gak perlu, hp penuh ๐Ÿ˜Š', + ]), + CheckinEntry('Konteks pertemuan kamu adalah...', [ + 'Self-reward ๐Ÿ™‹', + 'Date/Pertemuan penting ๐Ÿ’•', + 'Gathering/Arisan ๐ŸŽŠ', + 'Ngumpul biasa ๐Ÿ˜Š', + ]), + CheckinEntry('Lagi mau ngerasain suasana...', [ + 'Intimate (Sendiri) ๐ŸŒฟ', + 'Romantic/Close (Berdua) ๐Ÿ’•', + 'Energetic (Rame-rame) ๐ŸŽ‰', + 'Calm (Biasa) ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu lebih nyaman kalau...', [ + 'Gak ada yang kenal aku ๐Ÿ™‹', + 'Cuma dia yang kenal aku ๐Ÿ’•', + 'Semua orang tau kita seru ๐ŸŽŠ', + 'Biasa aja, sewajarnya ๐Ÿ˜Š', + ]), + CheckinEntry('Seberapa banyak piring di meja nanti?', [ + 'Satu piring saja ๐Ÿ™‹', + 'Dua piring cantik ๐Ÿ‘ซ', + 'Banyak piring berantakan ๐ŸŽ‰', + 'Beberapa piring standar ๐Ÿ˜Š', + ]), + CheckinEntry('Lagi mau "kabur" dari orang lain?', [ + 'Iya, mau sendiri dulu ๐Ÿ™‹', + 'Iya, mau berdua aja ๐Ÿ‘ซ', + 'Enggak, mau cari orang ๐ŸŽ‰', + 'Enggak, biasa aja ๐Ÿ˜Š', + ]), + CheckinEntry('Apa kamu bawa anak kecil?', [ + 'Enggak, sendirian ๐Ÿ™‹', + 'Enggak, cuma berdua ๐Ÿ‘ซ', + 'Iya, ada beberapa anak ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง', + 'Enggak, cuma orang dewasa ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu pengen dapet perhatian staf?', [ + 'Enggak, mau tenang ๐ŸŒฟ', + 'Secukupnya buat kami ๐Ÿ‘ซ', + 'Iya, buat bantu grup ๐ŸŽ‰', + 'Standar pelayanan aja ๐Ÿ˜Š', + ]), + CheckinEntry('Menu yang cocok buat statusmu...', [ + 'Single fighter ๐Ÿ™‹', + 'Dynamic duo ๐Ÿ‘ซ', + 'The big family ๐ŸŽŠ', + 'People in general ๐Ÿ˜Š', + ]), + CheckinEntry('Lagi pengen "curhat"?', [ + 'Curhat ke diri sendiri ๐Ÿ™‹', + 'Curhat ke sahabat ๐Ÿ’•', + 'Curhat masal/cerita seru ๐ŸŽ‰', + 'Gak curhat, cuma ngopi โ˜•', + ]), + CheckinEntry('Kamu butuh tempat yang luas gak?', [ + 'Enggak, kecil aja oke ๐Ÿ™‹', + 'Enggak, yang penting pas ๐Ÿ‘ซ', + 'Iya, biar gak kepisah ๐ŸŽ‰', + 'Biasa, yang penting nyaman ๐Ÿ˜Š', + ]), + CheckinEntry('Seberapa besar tagihan (bill) nanti?', [ + 'Kecil, buat sendiri ๐Ÿ™‹', + 'Sedang, buat berdua ๐Ÿ‘ซ', + 'Besar, bayar barengan ๐ŸŽ‰', + 'Normal, masing-masing ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu ngerasa "pede" datang...', [ + 'Sendirian ke kafe ๐Ÿ™‹', + 'Gandengan sama pacar ๐Ÿ’•', + 'Rame-rame sama geng ๐ŸŽŠ', + 'Santai bareng teman ๐Ÿ˜Š', + ]), + CheckinEntry('Apa kamu nunggu orang lain lagi?', [ + 'Enggak, cukup aku ๐Ÿ™‹', + 'Enggak, cuma berdua ๐Ÿ‘ซ', + 'Iya, masih banyak yang OTW ๐ŸŽ‰', + 'Mungkin, liat nanti ๐Ÿ˜Š', + ]), + CheckinEntry('Suasana mejamu bakal terlihat...', [ + 'Kosong dan rapi ๐ŸŒฟ', + 'Manis dan tertata ๐Ÿ’•', + 'Berantakan dan seru ๐ŸŽ‰', + 'Normal dan fungsional ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu ngerasa lebih produktif pas...', [ + 'Sendirian tanpa gangguan ๐Ÿ™‹', + 'Diskusi bareng teman ๐Ÿ‘ซ', + 'Di tengah keramaian grup ๐ŸŽ‰', + 'Biasa aja, gimanapun oke ๐Ÿ˜Š', + ]), + CheckinEntry('Apa kamu bawa laptop/buku?', [ + 'Iya, teman me-time ku ๐Ÿ’ป', + 'Iya, buat kerja bareng ๐Ÿ‘ซ', + 'Enggak, mau main aja ๐ŸŽ‰', + 'Enggak, cuma bawa hp ๐Ÿ“ฑ', + ]), + CheckinEntry('Lagi mau ngerayain ulang tahun?', [ + 'Enggak, me-time aja ๐Ÿ™‹', + 'Iya, ultah dia/aku ๐ŸŽ‚', + 'Iya, pesta kecil-kecilan ๐ŸŽŠ', + 'Enggak, hari biasa ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu mau duduk di mana?', [ + 'Pojok gelap (Sendiri) ๐ŸŒ™', + 'Tengah terang (Berdua) โ˜€๏ธ', + 'Sofa besar (Rame-rame) ๐Ÿ›‹๏ธ', + 'Kursi mana aja (Biasa) ๐Ÿ˜Š', + ]), + CheckinEntry('Seberapa kencang tawamu nanti?', [ + 'Gak ada tawa, cuma senyum ๐Ÿ™‚', + 'Tawa kecil sesekali ๐Ÿ˜Š', + 'Tawa kencang/heboh ๐Ÿ˜‚', + 'Tawa sewajarnya ๐Ÿ˜„', + ]), + CheckinEntry('Kamu butuh menu yang...', [ + 'Cepat habis (Sendiri) ๐Ÿ™‹', + 'Cantik difoto (Berdua) ๐Ÿ“ธ', + 'Banyak macamnya (Grup) ๐ŸŽŠ', + 'Enak dan kenyang (Biasa) ๐Ÿ˜Š', + ]), + CheckinEntry('Apa kamu ngerasa "kesepian"?', [ + 'Enggak, lagi menikmati ๐ŸŒฟ', + 'Enggak, ada dia ๐Ÿ’•', + 'Enggak, rame banget! ๐ŸŽ‰', + 'Biasa aja, gak kepikiran ๐Ÿ˜Š', + ]), + CheckinEntry('Seberapa "sosial" hari kamu?', [ + '0% (Anti-sosial) ๐Ÿ™‹', + '50% (Satu orang) ๐Ÿ‘ซ', + '100% (Social butterfly) ๐ŸŽŠ', + '25% (Casual) ๐Ÿ˜Š', + ]), + CheckinEntry('Kamu butuh rekomendasi buat...', [ + 'Diriku seutuhnya ๐Ÿ™‹', + 'Hubungan kami berdua ๐Ÿ’•', + 'Kebersamaan grup ini ๐ŸŽŠ', + 'Pengunjung umum ๐Ÿ˜Š', + ]), + CheckinEntry('Jadi, pesanan ini untuk siapa?', [ + 'Untuk aku seorang ๐Ÿ™‹', + 'Untuk kita berdua ๐Ÿ‘ซ', + 'Untuk rame-rame! ๐ŸŽŠ', + 'Untuk makan/minum biasa ๐Ÿ˜Š', + ]), + ]; + + // --------------------------------------------------------------------------- + // Nilai internal (keys) untuk tiap kategori โ€” disimpan ke Firestore + // --------------------------------------------------------------------------- + static const moodValues = ['senang', 'sedih', 'kecewa', 'marah']; + static const cuacaValues = ['hujan', 'cerah', 'dingin', 'panas']; + static const suasanaValues = ['sendiri', 'berdua', 'rame', 'biasa']; + + /// Ambil 1 pertanyaan per kategori berdasarkan hari ke-n dalam setahun. + static ({CheckinEntry mood, CheckinEntry cuaca, CheckinEntry suasana}) + getDailyEntries() { + final dayOfYear = DateTime.now() + .difference(DateTime(DateTime.now().year, 1, 1)) + .inDays; + return ( + mood: mood[dayOfYear % mood.length], + cuaca: cuaca[dayOfYear % cuaca.length], + suasana: suasana[dayOfYear % suasana.length], + ); + } +} diff --git a/lib/services/daily_checkin_service.dart b/lib/services/daily_checkin_service.dart new file mode 100644 index 0000000..74b04ee --- /dev/null +++ b/lib/services/daily_checkin_service.dart @@ -0,0 +1,110 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/firebase_service.dart'; + +class DailyCheckinService { + static final DailyCheckinService _instance = DailyCheckinService._internal(); + factory DailyCheckinService() => _instance; + DailyCheckinService._internal(); + + static const String _lastCheckinKey = 'checkin_last_date_'; + + // โ”€โ”€ Check if user needs to do daily check-in today โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future shouldShowCheckin(String userId) async { + try { + final userData = await FirebaseService().getUserData(userId); + if (userData == null) return false; + + final lastCheckin = userData.lastCheckinDate; + final now = DateTime.now(); + + if (lastCheckin == null) return true; + + final isSameDay = + lastCheckin.year == now.year && + lastCheckin.month == now.month && + lastCheckin.day == now.day; + + return !isSameDay; + } catch (e) { + debugPrint('Error checking daily check-in status: $e'); + return false; + } + } + + // โ”€โ”€ Save daily preferences to Firestore โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future saveDailyPreferences( + String userId, + Map preferences, + ) async { + try { + final userData = await FirebaseService().getUserData(userId); + if (userData == null) throw Exception('User not found'); + + // โ”€โ”€ Sinkronkan keinginan harian โ†’ favoriteCategories di profil โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Agar preferensi profil selalu selaras dengan keinginan harian user + final String keinginan = (preferences['keinginan'] as String?) ?? ''; + List updatedFavCategories = + List.from(userData.preferences.favoriteCategories); + + if (keinginan == 'minuman') { + // Hapus semua kategori makanan, pertahankan kategori minuman + updatedFavCategories.removeWhere((c) => + c.toLowerCase().contains('makanan') || + c.toLowerCase().contains('snack') || + c.toLowerCase().contains('dessert') || + c.toLowerCase().contains('pastry') || + c.toLowerCase().contains('sarapan') || + c.toLowerCase().contains('berkuah')); + // Tambahkan Minuman Dingin & Minuman Panas jika belum ada + if (!updatedFavCategories.any((c) => c.toLowerCase().contains('minuman'))) { + updatedFavCategories.add('Minuman Dingin'); + } + } else if (keinginan == 'makanan') { + // Hapus semua kategori minuman, pertahankan kategori makanan + updatedFavCategories.removeWhere((c) => + c.toLowerCase().contains('minuman')); + // Tambahkan Makanan Berat jika kosong + if (updatedFavCategories.isEmpty) { + updatedFavCategories.add('Makanan Berat'); + } + } + // Jika 'keduanya', tidak ada perubahan pada favoriteCategories + + final updatedPreferences = userData.preferences.copyWith( + favoriteCategories: updatedFavCategories, + ); + + final updatedUser = userData.copyWith( + lastCheckinDate: DateTime.now(), + dailyPreferences: preferences, + preferences: updatedPreferences, + ); + + await FirebaseService().updateUserData(updatedUser); + + // also cache locally so shouldShowCheckin is fast next call + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + '$_lastCheckinKey$userId', + DateTime.now().toIso8601String(), + ); + + debugPrint('โœ… Daily preferences saved successfully'); + debugPrint('โœ… favoriteCategories updated to: $updatedFavCategories'); + } catch (e) { + debugPrint('โŒ Error saving daily preferences: $e'); + rethrow; + } + } + + // โ”€โ”€ Clear local cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Future clearCache(String userId) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('$_lastCheckinKey$userId'); + } catch (e) { + debugPrint('โŒ Error clearing cache: $e'); + } + } +} diff --git a/lib/services/daily_question_dataset.dart b/lib/services/daily_question_dataset.dart new file mode 100644 index 0000000..50c2d4f --- /dev/null +++ b/lib/services/daily_question_dataset.dart @@ -0,0 +1,5070 @@ +import '../models/daily_question_model.dart'; + +/// 50 rotating branching question sets for daily check-in. +/// Each set has core questions (always shown) and branching +/// follow-ups that appear based on the user's answers. +class DailyQuestionDataset { + DailyQuestionDataset._(); + + static List getQuestionsForToday(String userName) { + final dayOfYear = DateTime.now() + .difference(DateTime(DateTime.now().year, 1, 1)) + .inDays; + final setIndex = dayOfYear % _sets.length; + return _personalize(_sets[setIndex], userName); + } + + static List getDefaultQuestions(String userName) { + return _personalize(_sets[0], userName); + } + + static List _personalize( + List questions, + String name, + ) { + return questions + .map( + (q) => DailyQuestion( + id: q.id, + question: q.question.replaceAll('{name}', name), + type: q.type, + options: q.options, + minValue: q.minValue, + maxValue: q.maxValue, + step: q.step, + dependsOn: q.dependsOn, + showWhenAnswers: q.showWhenAnswers, + ), + ) + .toList(); + } + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // HELPER BUILDERS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + static DailyQuestion _choice( + String id, + String q, + List opts, { + String? dep, + List? when, + }) => DailyQuestion( + id: id, + question: q, + type: QuestionType.choice, + options: opts, + dependsOn: dep, + showWhenAnswers: when, + ); + + static DailyQuestion _slider( + String id, + String q, { + int min = 10000, + int max = 100000, + int step = 5000, + String? dep, + List? when, + }) => DailyQuestion( + id: id, + question: q, + type: QuestionType.slider, + minValue: min, + maxValue: max, + step: step, + dependsOn: dep, + showWhenAnswers: when, + ); + + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // 50 QUESTION SETS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + static final List> _sets = [ + // โ”€โ”€ SET 1 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Hari ini mau ngapain?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Mau ngopi hari ini?', ['Kopi', 'Tidak']), + _choice('mood', 'Mood kamu sekarang?', [ + 'Energik', + 'Santai', + 'Bahagia', + 'Fokus', + 'Lelah', + ]), + // Makan branch + _choice( + 'mealType', + 'Mau makan apa?', + ['Nasi', 'Mie', 'Roti/Burger', 'Seafood', 'Sayuran'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget makan kamu?', + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Level pedas?', + ['Tidak Pedas', 'Sedikit Pedas', 'Sedang', 'Pedas', 'Extra Pedas'], + dep: 'intention', + when: ['Makan'], + ), + // Ngemil branch + _choice( + 'snackType', + 'Ngemil apa nih?', + ['Gorengan', 'Kue', 'Keripik', 'Buah', 'Roti'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget ngemil?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'snackFlavor', + 'Rasa snack?', + ['Asin', 'Manis', 'Pedas', 'Asam', 'Gurih'], + dep: 'intention', + when: ['Ngemil'], + ), + // Kopi branch + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Manis', + 'Minuman Segar', + 'Minuman Susu/Creamy', + 'Minuman Sehat', + 'Air Putih', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Panas/Hangat', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tanpa Gula', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 2 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Rencana makan hari ini?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Pengen kopi?', ['Kopi', 'Tidak']), + _choice('mood', 'Perasaan kamu?', [ + 'Senang', + 'Biasa', + 'Stress', + 'Semangat', + 'Galau', + ]), + _choice( + 'mealType', + 'Jenis makanan?', + ['Nasi Goreng', 'Soto', 'Bakso', 'Gado-gado', 'Pecel'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget berapa?', dep: 'intention', when: ['Makan']), + _choice( + 'portion', + 'Porsi?', + ['Kecil', 'Sedang', 'Besar', 'Extra'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack apa?', + ['Martabak', 'Pisang Goreng', 'Bakwan', 'Risoles', 'Lumpia'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget snack?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Hitam (Pahit)', 'Kopi Susu (Creamy)', 'Kopi Varian (Manis)'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Segar', + 'Minuman Manis', + 'Minuman Susu/Creamy', + 'Minuman Hangat', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Panas', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 3 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Mau makan atau ngemil?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Ngopi dulu?', ['Kopi', 'Tidak']), + _choice('energy', 'Level energi?', [ + 'Rendah', + 'Biasa', + 'Tinggi', + 'Super', + ]), + _choice( + 'mealType', + 'Menu apa?', + ['Ayam Geprek', 'Rendang', 'Ikan Bakar', 'Sate', 'Rawon'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Pedas?', + ['Tidak', 'Sedikit', 'Sedang', 'Pedas', 'Gila Pedas'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'texture', + 'Tekstur makanan?', + ['Crispy', 'Lembut', 'Kenyal', 'Berkuah'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Ngemil apa?', + ['Cireng', 'Batagor', 'Siomay', 'Tahu Bulat', 'Cilok'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget ngemil?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Preferensi kopi?', + ['Kopi Hitam', 'Kopi Susu/Creamy', 'Kopi Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar', 'Minuman Creamy/Susu', 'Minuman Manis', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Panas', 'Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 4 โ”€โ”€ + [ + _choice('intention', '{name}, mau makan/ngemil/skip?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Butuh kafein?', ['Kopi', 'Tidak']), + _choice('weather', 'Cuaca hari ini?', [ + 'Panas', + 'Hangat', + 'Sejuk', + 'Hujan', + ]), + _choice( + 'mealType', + 'Cuisine pilihan?', + ['Indonesia', 'Jawa', 'Sunda', 'Padang', 'Western'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget makan?', dep: 'intention', when: ['Makan']), + _choice( + 'portion', + 'Seberapa banyak?', + ['Sedikit', 'Normal', 'Banyak', 'All You Can Eat'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Camilan apa?', + ['Es Krim', 'Donat', 'Brownis', 'Pancake', 'Waffle'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget camilan?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Preferensi kopi?', + ['Kopi Hitam/Strong', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Double'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Hangat', 'Minuman Segar', 'Minuman Susu/Creamy', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Panas', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 5 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Lapar atau ngemil aja?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi atau skip?', ['Kopi', 'Tidak']), + _choice('activity', 'Aktivitas hari ini?', [ + 'Kerja', + 'Kuliah', + 'Olahraga', + 'Santai', + 'Jalan-jalan', + ]), + _choice( + 'mealType', + 'Menu idaman?', + ['Nasi Uduk', 'Nasi Kuning', 'Lontong', 'Ketoprak', 'Bubur'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Anggarannya?', dep: 'intention', when: ['Makan']), + _choice( + 'dietary', + 'Pantangan?', + [ + 'Tidak Ada', + 'Tanpa Gorengan', + 'Rendah Gula', + 'No Santan', + 'Vegetarian', + ], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Ngemil apa ya?', + ['Kentang Goreng', 'Nugget', 'Sosis Bakar', 'Dimsum', 'Tempura'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi style?', + ['Kopi Hitam/Manual', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Creamy/Susu', + 'Minuman Segar', + 'Minuman Manis', + 'Minuman Hangat', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Panas', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa manis?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 6 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Perut bilang apa?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi dulu ga?', ['Kopi', 'Tidak']), + _choice('mood', 'Suasana hati?', [ + 'Happy', + 'Chill', + 'Produktif', + 'Moody', + 'Excited', + ]), + _choice( + 'mealType', + 'Masakan apa?', + ['Mie Ayam', 'Kwetiau', 'Bihun', 'Capcay', 'Fuyunghai'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Suka pedas?', + ['Ga Pedas', 'Dikit', 'Medium', 'Hot', 'Extreme'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Camilan favorit?', + ['Onde-onde', 'Klepon', 'Getuk', 'Lapis Legit', 'Putu'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget ngemil?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Gaya kopi?', + ['Kopi Hitam', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis level?', + ['Pahit', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Manis', + 'Minuman Segar/Es', + 'Minuman Hangat', + 'Minuman Susu/Creamy', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Panas', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 7 โ”€โ”€ + [ + _choice('intention', '{name}, makan besar atau ringan?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Ngopi yuk?', ['Kopi', 'Tidak']), + _choice('stress', 'Level stress?', [ + 'Santai', + 'Biasa', + 'Lumayan', + 'Tinggi', + 'Butuh Healing', + ]), + _choice( + 'mealType', + 'Menu hari ini?', + ['Nasi Padang', 'Gudeg', 'Pempek', 'Coto Makassar', 'Papeda'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'protein', + 'Protein?', + ['Ayam', 'Ikan', 'Daging', 'Telur', 'Tahu/Tempe'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack tradisional?', + ['Serabi', 'Lupis', 'Cenil', 'Kue Cubit', 'Kue Ape'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi?', + ['Kopi Hitam/Manual', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Normal', 'Manis Pas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Pilihan minuman?', + [ + 'Minuman Susu/Creamy', + 'Minuman Segar/Dingin', + 'Minuman Hangat/Tradisional', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Panas', 'Dingin', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa manis?', + ['Tawar/Alami', 'Sedang', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 8 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Makan atau ngemil?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Butuh kopi?', ['Kopi', 'Tidak']), + _choice('appetite', 'Seberapa lapar?', [ + 'Sedikit', + 'Lumayan', + 'Lapar', + 'Kelaparan', + ]), + _choice( + 'mealType', + 'Mau apa?', + ['Pizza', 'Pasta', 'Steak', 'Salad', 'Sandwich'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Anggarannya?', dep: 'intention', when: ['Makan']), + _choice( + 'portion', + 'Porsi berapa?', + ['Small', 'Medium', 'Large', 'Extra Large'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack western?', + ['Croissant', 'Muffin', 'Cookie', 'Pretzel', 'Bagel'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget snack?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Prefensi kopi?', + ['Kopi Hitam/Kuat', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Sedikit Manis', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Segar/Buah', + 'Minuman Manis', + 'Minuman Susu/Creamy', + 'Air Putih', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Panas', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 9 โ”€โ”€ + [ + _choice('intention', '{name}! Sarapan, makan, atau ngemil?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi pagi?', ['Kopi', 'Tidak']), + _choice('mood', 'Mood pagi ini?', [ + 'Fresh', + 'Ngantuk', + 'Semangat', + 'Males', + 'Excited', + ]), + _choice( + 'mealType', + 'Sarapan apa?', + [ + 'Bubur Ayam', + 'Nasi Goreng', + 'Lontong Sayur', + 'Roti Bakar', + 'Nasi Kucing', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget sarapan?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Pagi-pagi pedas?', + ['Jangan', 'Sedikit Aja', 'Boleh', 'Gas Pedas'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Cemilan pagi?', + ['Roti', 'Biskuit', 'Granola Bar', 'Buah', 'Yogurt'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Hitam/Kuat', 'Kopi Susu/Nyaman', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Hangat', + 'Minuman Susu/Creamy', + 'Minuman Segar/Jus', + 'Air Putih', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Hangat/Panas', 'Dingin', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 10 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Makan siang nih?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi siang?', ['Kopi', 'Tidak']), + _choice('energy', 'Energi siang?', [ + 'Drop', + 'Biasa', + 'Masih Oke', + 'Full Power', + ]), + _choice( + 'mealType', + 'Lunch menu?', + ['Nasi Campur', 'Nasi Liwet', 'Nasi Timbel', 'Nasi Bakar', 'Rice Bowl'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget lunch?', dep: 'intention', when: ['Makan']), + _choice( + 'dining', + 'Makan di mana?', + ['Kantin', 'Resto', 'Warteg', 'Delivery', 'Bawa Bekal'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack siang?', + ['Tahu Crispy', 'Bakso Tusuk', 'Sosis Solo', 'Pukis', 'Martabak Mini'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tipe kopi?', + ['Kopi Susu/Creamy', 'Kopi Hitam/Kuat', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Kemanisan?', + ['Tanpa Gula', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas', 'Biasa'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar', 'Minuman Manis', 'Minuman Susu/Creamy', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Dingin Pas', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tanpa Gula', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 11 โ”€โ”€ + [ + _choice('intention', '{name}, dinner plan?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi malam?', ['Kopi', 'Tidak']), + _choice('mood', 'Vibe malam ini?', [ + 'Romantis', + 'Chill', + 'Lapar Berat', + 'Santai', + 'Celebration', + ]), + _choice( + 'mealType', + 'Dinner?', + ['Steak', 'BBQ', 'Sushi', 'Korean Food', 'Chinese Food'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget dinner?', + min: 20000, + max: 150000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'portion', + 'Porsi malam?', + ['Light', 'Normal', 'Heavy', 'Sharing'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack malam?', + ['Popcorn', 'Nachos', 'Roti Bakar', 'Indomie', 'Seblak'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi malam?', + ['Kopi Ringan (Decaf)', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Level manis?', + ['Pahit', 'Pas/Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Minum malam?', + ['Minuman Hangat/Rileks', 'Minuman Susu/Creamy', 'Minuman Sehat/Alami'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Hangat/Panas', 'Suhu Ruang', 'Dingin'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar/Alami', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 12 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Lagi ngidam makan?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Ngopi ga?', ['Kopi', 'Tidak']), + _choice('craving', 'Ngidam rasa apa?', [ + 'Gurih', + 'Manis', + 'Asam', + 'Pedas', + 'Segar', + ]), + _choice( + 'mealType', + 'Ngidam apa?', + ['Nasi Goreng Kampung', 'Sop Buntut', 'Iga Bakar', 'Tongseng', 'Gulai'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Pedasnya?', + ['Skip', 'Mild', 'Medium', 'Hot', 'Volcano'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack ngidam?', + ['Cireng Rujak', 'Baso Aci', 'Seblak', 'Cimol', 'Sosis Pedas'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget snack?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi ngidam?', + ['Kopi Susu/Creamy', 'Kopi Hitam/Segar', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar/Dingin', 'Minuman Susu/Creamy', 'Minuman Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Dingin Pas', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Manis?', + ['Kurang Gula', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 13 โ”€โ”€ + [ + _choice('intention', '{name}, perut sudah bunyi?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kafein call?', ['Kopi', 'Tidak']), + _choice('health', 'Hari ini mau sehat?', [ + 'Super Sehat', + 'Balanced', + 'Cheat Day', + 'YOLO', + ]), + _choice( + 'mealType', + 'Pilihan sehat?', + ['Salad Bowl', 'Poke Bowl', 'Sup Sayur', 'Pecel', 'Urap'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'protein', + 'Protein pilihan?', + ['Ayam Panggang', 'Ikan Steam', 'Tahu', 'Tempe', 'Telur Rebus'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Healthy snack?', + ['Buah Potong', 'Granola', 'Kacang', 'Yogurt', 'Dried Fruit'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Hitam Polos', 'Kopi Susu/Creamy', 'Minuman Sehat/Sejuk'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Pemanis?', + ['Tanpa Gula', 'Normal', 'Madu/Alami'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Air Putih/Sehat', 'Minuman Segar/Alami', 'Minuman Sehat/Nabati'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Suhu Ruang', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar/Alami', 'Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 14 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Comfort food atau diet?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi comfort?', ['Kopi', 'Tidak']), + _choice('mood', 'Butuh comfort?', [ + 'Banget', + 'Lumayan', + 'Biasa Aja', + 'Lagi Happy', + ]), + _choice( + 'mealType', + 'Comfort food?', + ['Indomie', 'Nasi Goreng', 'Bakso', 'Soto Ayam', 'Mie Ayam'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Level pedas comfort?', + ['Original', 'Tambah Cabe', 'Pedas', 'Pedas Mampus'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Comfort snack?', + ['Coklat', 'Keripik', 'Es Krim', 'Roti Coklat', 'Pisang Goreng Keju'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi favorit?', + ['Kopi Hitam/Manual', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Level manis?', + ['Less Sugar', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Susu/Creamy', + 'Minuman Hangat/Rileks', + 'Minuman Hangat/Sehat', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Hangat/Panas', 'Dingin'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Normal', 'Manis Pas', 'Manis Banget'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 15 โ”€โ”€ + [ + _choice('intention', '{name}, makan berat atau ringan?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Need caffeine?', ['Kopi', 'Tidak']), + _choice('time', 'Ini untuk kapan?', [ + 'Sekarang', + 'Nanti Siang', + 'Sore', + 'Malam', + ]), + _choice( + 'mealType', + 'Heavy meal?', + [ + 'Nasi Liwet Komplit', + 'Sei Sapi', + 'Bebek Goreng', + 'Ayam Betutu', + 'Babi Guling', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget?', + min: 15000, + max: 120000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'texture', + 'Maunya?', + ['Goreng Kering', 'Berkuah', 'Bakar', 'Panggang', 'Steam'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Light snack?', + ['Egg Tart', 'Siu Mai', 'Spring Roll', 'Gyoza', 'Pangsit'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Hitam/Kuat', 'Kopi Susu/Creamy', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Hangat', 'Minuman Segar/Sehat', 'Minuman Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 16 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Street food atau resto?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi street?', ['Kopi', 'Tidak']), + _choice('mood', 'Mood jajan?', [ + 'Explore', + 'Favorit Aja', + 'Random', + 'Hemat', + 'Treat Yourself', + ]), + _choice( + 'mealType', + 'Street food?', + [ + 'Nasi Goreng Gerobak', + 'Mie Tek-tek', + 'Martabak Telor', + 'Sate Kambing', + 'Pecel Lele', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget street food?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Sambel?', + ['Tanpa Sambel', 'Sambel Dikit', 'Sambel Banyak', 'Sambel Extra'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Jajanan pasar?', + ['Lemper', 'Risoles', 'Pastel', 'Kue Lapis', 'Nagasari'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget jajan?', + min: 2000, + max: 30000, + step: 2000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi style?', + ['Kopi Hitam (Manual)', 'Kopi Susu (Creamy)', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Manis Banget'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar', 'Minuman Manis', 'Minuman Susu/Creamy', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Dingin', 'Panas'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 17 โ”€โ”€ + [ + _choice('intention', '{name}! Makan bareng atau sendirian?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Coffee date?', ['Kopi', 'Tidak']), + _choice('companion', 'Makan sama siapa?', [ + 'Sendiri', + 'Teman', + 'Pacar', + 'Keluarga', + 'Rekan Kerja', + ]), + _choice( + 'mealType', + 'Menu bareng?', + [ + 'All You Can Eat', + 'Hotpot', + 'BBQ Korean', + 'Seafood Bakar', + 'Prasmanan', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget per orang?', + min: 20000, + max: 150000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'portion', + 'Sharing?', + ['Personal', 'Sharing 2', 'Sharing 4', 'Rame-rame'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack bareng?', + ['Platter', 'Pizza', 'Takoyaki', 'Dimsum', 'Nachos'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 80000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Susu (Creamy)', 'Kopi Hitam (Kuat)', 'Kopi Varian (Manis)'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Pas/Normal', 'Manis Banget'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas/Hangat'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Susu/Creamy', + 'Minuman Segar/Es', + 'Minuman Manis', + 'Air Putih', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Panas', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 18 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Makan sehat hari ini?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi organik?', ['Kopi', 'Tidak']), + _choice('goal', 'Goal makan?', [ + 'Diet', + 'Bulking', + 'Maintain', + 'No Goal', + ]), + _choice( + 'mealType', + 'Healthy menu?', + ['Chicken Breast', 'Salmon', 'Tofu Steak', 'Quinoa Bowl', 'Wrap'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget sehat?', + min: 15000, + max: 100000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'protein', + 'Sumber protein?', + ['Dada Ayam', 'Ikan', 'Telur', 'Kacang-kacangan', 'Susu'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Healthy snack?', + ['Protein Bar', 'Trail Mix', 'Edamame', 'Hummus', 'Dark Chocolate'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi sehat?', + ['Kopi Hitam Polos', 'Kopi Susu/Creamy', 'Kopi Sehat'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Pemanis?', + ['Tanpa Gula', 'Madu/Alami', 'Sedikit'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar/Alami', 'Minuman Sehat/Sejuk', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Suhu Ruang', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar/Alami', 'Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 19 โ”€โ”€ + [ + _choice('intention', '{name}! Weekend vibes, mau apa?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Weekend coffee?', ['Kopi', 'Tidak']), + _choice('vibe', 'Weekend mood?', [ + 'Lazy', + 'Adventurous', + 'Cozy', + 'Party', + 'Healing', + ]), + _choice( + 'mealType', + 'Weekend feast?', + ['Brunch', 'BBQ', 'Buffet', 'Seafood', 'Fine Dining'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Weekend budget?', + min: 20000, + max: 200000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Pedas weekend?', + ['Kalem', 'Sedang', 'Pedas', 'Challenge'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Weekend snack?', + ['Acai Bowl', 'Churros', 'Crepe', 'Gelato', 'Banana Split'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 80000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Weekend coffee?', + ['Kopi Hitam/Kuat', 'Kopi Susu/Creamy', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Slow', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Weekend drink?', + [ + 'Minuman Segar/Es', + 'Minuman Susu/Creamy', + 'Minuman Manis', + 'Air Putih', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Suhu Ruang', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Manis?', + ['Normal', 'Pas', 'Extra Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 20 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Hari ini makan apa ya?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kafein?', ['Kopi', 'Tidak']), + _choice('mood', 'Feeling?', [ + 'Productive', + 'Dreamy', + 'Hungry', + 'Zen', + 'Playful', + ]), + _choice( + 'mealType', + 'Asian food?', + ['Ramen', 'Pad Thai', 'Pho', 'Dim Sum', 'Bibimbap'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'texture', + 'Texture?', + ['Soupy', 'Crunchy', 'Chewy', 'Smooth', 'Mixed'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Asian snack?', + ['Mochi', 'Taiyaki', 'Egg Waffle', 'Onigiri', 'Gyoza'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tipe kopi?', + ['Kopi Susu (Creamy)', 'Kopi Hitam/Kuat', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Less Sugar', 'Normal', 'Manis Banget'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Susu/Creamy', + 'Minuman Segar', + 'Minuman Sehat/Alami', + 'Air Putih', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Suhu Ruang', 'Panas'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 21 โ”€โ”€ + [ + _choice('intention', '{name}, makan berat atau ringan nih?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Ngopi?', ['Kopi', 'Tidak']), + _choice('appetite', 'Nafsu makan?', [ + 'Sedikit Banget', + 'Lumayan', + 'Normal', + 'Besar', + 'Rakus', + ]), + _choice( + 'mealType', + 'Masakan rumah?', + [ + 'Sayur Lodeh', + 'Opor Ayam', + 'Semur', + 'Tumis Kangkung', + 'Sambal Goreng', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Pedas?', + ['Tidak', 'Sedang', 'Pedas', 'Mantap'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Jajan?', + ['Pisang Molen', 'Sus Kering', 'Kue Lumpur', 'Bika Ambon', 'Kue Ku'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 40000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tipe kopi?', + ['Kopi Hitam/Manual', 'Kopi Susu Tradisional', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Sehat/Alami', 'Minuman Hangat', 'Minuman Segar/Es'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Hangat/Panas', 'Dingin/Es', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Sedikit Manis', 'Manis Pas'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 22 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Culinary adventure?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Coffee adventure?', ['Kopi', 'Tidak']), + _choice('mood', 'Adventurous level?', [ + 'Main Aman', + 'Coba Baru', + 'Full Explore', + 'Surprise Me', + ]), + _choice( + 'mealType', + 'Masakan daerah?', + [ + 'Masakan Manado', + 'Masakan Bali', + 'Masakan Aceh', + 'Masakan Banjar', + 'Masakan Papua', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget explore?', + min: 15000, + max: 120000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Berani pedas?', + ['Aman Aja', 'Coba Dikit', 'Hajar', 'Sambel Terasi Full'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack daerah?', + ['Kerak Telor', 'Surabi Bandung', 'Mpek-mpek', 'Wingko Babat', 'Leker'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Hitam/Origin', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gulanya?', + ['Tanpa Gula', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar/Es', 'Minuman Hangat', 'Minuman Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Panas', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Manis?', + ['Pas', 'Extra Sweet', 'Less Sugar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 23 โ”€โ”€ + [ + _choice('intention', '{name}, isi perut dulu?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi dulu bos?', ['Kopi', 'Tidak']), + _choice('time', 'Makan untuk kapan?', [ + 'Sekarang', + '1 Jam Lagi', + 'Nanti Siang', + 'Nanti Malam', + ]), + _choice( + 'mealType', + 'Menu kampus?', + ['Nasi Warteg', 'Ayam Geprek', 'Mie Goreng', 'Nasi Telur', 'Bakso'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget anak kos?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'portion', + 'Nambah nasi?', + ['Cukup', 'Nambah 1', 'Nambah 2', 'Sepuasnya'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Jajan kampus?', + ['Gorengan', 'Bala-bala', 'Tahu Isi', 'Combro', 'Misro'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget jajan?', + min: 2000, + max: 20000, + step: 2000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi budget?', + ['Kopi Susu/Creamy', 'Kopi Hitam/Manual', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Es/panas?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Normal', 'Manis', 'Pahit'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar/Es', 'Minuman Manis/Sachet', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Suhu Ruang', 'Panas'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Manis?', + ['Pas', 'Extra Manis', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 24 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Payday nih, makan enak?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Treat yourself coffee?', ['Kopi', 'Tidak']), + _choice('mood', 'Payday mood?', [ + 'Sultan', + 'Treat', + 'Hemat Tetap', + 'Traktir Teman', + ]), + _choice( + 'mealType', + 'Makan mewah?', + ['Wagyu', 'Lobster', 'Lamb Rack', 'Duck Confit', 'Truffle Pasta'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget sultan?', + min: 50000, + max: 300000, + step: 25000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'dining', + 'Tempat?', + ['Fine Dining', 'Rooftop', 'Hidden Gem', 'Hotel Restaurant', 'Omakase'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Premium snack?', + ['Macarons', 'Tiramisu', 'Crรจme Brรปlรฉe', 'Tart', 'Fondue'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 20000, + max: 150000, + step: 10000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi premium?', + ['Kopi Hitam/Origin', 'Kopi Susu/Specialty', 'Kopi Varian/Modern'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Sweetness?', + ['Pure Black', 'Slightly Sweet', 'Balanced', 'Sweet/Modern'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Temp?', + ['Hot', 'Iced/Cold'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + [ + 'Minuman Segar/Pilihan', + 'Minuman Varian/Signature', + 'Air Putih/Premium', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Temperature?', + ['Iced', 'Room Temp', 'Hot'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Taste?', + ['Natural/Tanpa Gula', 'Manis Pas'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 25 โ”€โ”€ + [ + _choice('intention', '{name}, pengen yang pedas?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi?', ['Kopi', 'Tidak']), + _choice('spiceToday', 'Level pedas hari ini?', [ + 'Ga Kuat Pedas', + 'Mild', + 'Medium', + 'Fire', + 'Insane', + ]), + _choice( + 'mealType', + 'Menu pedas?', + [ + 'Seblak', + 'Ayam Geprek Nuclear', + 'Mie Setan', + 'Cumi Pedas', + 'Sambal Matah', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'protein', + 'Protein pedas?', + ['Ayam', 'Cumi', 'Udang', 'Ikan', 'Tahu/Tempe'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack pedas?', + [ + 'Basreng Pedas', + 'Makaroni Pedas', + 'Keripik Pedas', + 'Cimol Pedas', + 'Seblak Kering', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Susu Creamy', 'Hitam Segar', 'Kopi Manis/Mocha'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Pahit', 'Normal', 'Manis', 'Extra'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Penawar pedas?', + ['Susu/Diary', 'Minuman Manis Segar', 'Yoghurt/Asam'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Dingin Biasa', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Manis?', + ['Manis Banget', 'Sedang', 'Natural'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 26 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Hari Senin, butuh energi?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Monday coffee?', ['Kopi', 'Tidak']), + _choice('energy', 'Monday energy?', [ + 'Zombie', + 'Low Energy', + 'Okay', + 'Charged', + 'Overclocked', + ]), + _choice( + 'mealType', + 'Power meal?', + [ + 'Nasi Rendang', + 'Ayam Bakar Madu', + 'Ikan Panggang', + 'Sop Kambing', + 'Nasi Kebuli', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'portion', + 'Porsi energi?', + ['Secukupnya', 'Full Tank', 'Overload'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Energy snack?', + ['Energy Bar', 'Banana', 'Dates', 'Mixed Nuts', 'Oat Cookie'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi energi?', + ['Kopi Hitam/Kuat', 'Kopi Susu (Creamy)', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar', 'Minuman Sehat/Sejuk', 'Minuman Susu/Creamy'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin Banget', 'Dingin', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 27 โ”€โ”€ + [ + _choice('intention', '{name}! Makan apa hari ini?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Coffee break?', ['Kopi', 'Tidak']), + _choice('mood', 'Mood makan?', [ + 'Nostalgia', + 'Modern', + 'Fusion', + 'Classic', + 'Experimental', + ]), + _choice( + 'mealType', + 'Nostalgia food?', + [ + 'Nasi Uduk + Bihun', + 'Soto Betawi', + 'Kupat Tahu', + 'Tahu Gejrot', + 'Mie Kocok', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Sambel?', + [ + 'Tanpa', + 'Sambel Kecap', + 'Sambel Terasi', + 'Sambel Ijo', + 'Sambel Matah', + ], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack nostalgia?', + [ + 'Kue Rangi', + 'Kembang Goyang', + 'Kue Pancong', + 'Ali Agrem', + 'Kue Cucur', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 3000, + max: 25000, + step: 2000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi klasik?', + ['Kopi Susu Tradisional', 'Kopi Hitam/Tubruk', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Manis Pas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Manis', 'Minuman Segar', 'Minuman Susu/Creamy'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Sangat Dingin', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Normal', 'Extra Manis', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 28 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Lagi diet ga?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Diet coffee?', ['Kopi', 'Tidak']), + _choice('diet', 'Jenis diet?', [ + 'No Diet', + 'Low Carb', + 'High Protein', + 'Intermittent Fasting', + 'Clean Eating', + ]), + _choice( + 'mealType', + 'Diet menu?', + [ + 'Grilled Chicken Salad', + 'Sashimi', + 'Sup Sayur', + 'Telur Rebus + Alpukat', + 'Wrap Sayur', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget diet?', + min: 15000, + max: 80000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'portion', + 'Porsi diet?', + ['Mini', 'Controlled', 'Normal', 'Cheat Portion'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Diet snack?', + [ + 'Celery Stick', + 'Rice Cake', + 'Greek Yogurt', + 'Cherry Tomato', + 'Almonds', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi diet?', + ['Kopi Hitam Polos', 'Kopi Susu Rendah Lemak', 'Kopi Segar/Drip'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'No sugar?', + ['Zero/Tanpa Gula', 'Pemanis Alami', 'Sedikit Madu'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Air Putih/Sehat', 'Minuman Sehat/Sejuk', 'Minuman Alami'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Suhu Ruang', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Taste?', + ['Plain/Tawar', 'Sangat Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 29 โ”€โ”€ + [ + _choice('intention', '{name}! Hujan-hujan enak makan apa?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi hangat?', ['Kopi', 'Tidak']), + _choice('weather', 'Cuaca sekarang?', [ + 'Gerimis', + 'Hujan Deras', + 'Mendung', + 'Dingin', + 'Angin Kencang', + ]), + _choice( + 'mealType', + 'Comfort in rain?', + ['Soto', 'Sup Bening', 'Bakmi Kuah', 'Tongseng', 'Rawon'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'warmth', + 'Level hangat?', + ['Hangat', 'Panas', 'Super Panas', 'Pedas Panas'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack hujan?', + ['Pisang Goreng', 'Bakwan', 'Ubi Goreng', 'Singkong Keju', 'Tahu Isi'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi hangat?', + ['Kopi Hitam/Kuat', 'Kopi Susu/Creamy', 'Kopi Varian/Rempah'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Hangat'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Pahit', 'Pas', 'Normal'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Minuman hangat?', + ['Minuman Hangat/Sehat', 'Minuman Susu/Creamy', 'Air Putih/Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Panas', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Manis Pas', 'Sedikit Manis', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 30 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Quick meal atau slow?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Quick coffee?', ['Kopi', 'Tidak']), + _choice('pace', 'Kecepatan makan?', [ + 'Express 5 Menit', + 'Quick 15 Menit', + 'Santai 30 Menit', + 'No Rush', + ]), + _choice( + 'mealType', + 'Fast food?', + ['Fried Chicken', 'Burger', 'Kebab', 'Takoyaki', 'Onigiri'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget cepat?', + min: 10000, + max: 60000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Quick sambel?', + ['Skip', 'Sachet', 'Extra', 'Banjir'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Grab & go?', + ['Onigiri', 'Sandwich', 'Roti Isi', 'Cup Noodle', 'Granola Bar'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Cepat saji?', + ['Kopi Hitam/Segar', 'Kopi Susu/Creamy', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Minuman praktis?', + ['Air Putih', 'Minuman Segar/Dingin', 'Minuman Buah/Susu'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 31 โ”€โ”€ + [ + _choice('intention', '{name}, makannya bareng siapa?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Date coffee?', ['Kopi', 'Tidak']), + _choice('companion', 'Sama siapa?', [ + 'Solo', + 'Best Friend', + 'Gebetan', + 'Keluarga Besar', + 'Tim Kantor', + ]), + _choice( + 'mealType', + 'Social meal?', + ['Hotpot', 'AYCE BBQ', 'Shabu-shabu', 'Pizza Sharing', 'Nasi Tumpeng'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget per orang?', + min: 25000, + max: 200000, + step: 25000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'dining', + 'Tempat?', + ['Mall', 'Cafe', 'Resto', 'Food Court', 'Rumah'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Sharing snack?', + [ + 'Platter Mix', + 'French Fries XL', + 'Wings Bucket', + 'Sushi Roll', + 'Cheese Board', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 20000, + max: 100000, + step: 10000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tipe kopi?', + ['Kopi Hitam/Manual', 'Kopi Susu/Creamy', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Manis Pas', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas/Hangat'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar/Es', 'Minuman Susu/Creamy', 'Minuman Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Normal', 'Extra Sweet', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 32 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Makan sebelum olahraga?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Pre-workout coffee?', ['Kopi', 'Tidak']), + _choice('workout', 'Olahraga apa?', [ + 'Gym', + 'Jogging', + 'Renang', + 'Yoga', + 'Basket/Futsal', + ]), + _choice( + 'mealType', + 'Pre-workout meal?', + [ + 'Oatmeal', + 'Roti Gandum + Selai', + 'Nasi + Ayam Rebus', + 'Pasta', + 'Ubi Rebus', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget?', + min: 10000, + max: 60000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'timing', + 'Makan kapan?', + [ + '2 Jam Sebelum', + '1 Jam Sebelum', + '30 Menit Sebelum', + 'Sesudah Olahraga', + ], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Pre-workout snack?', + ['Pisang', 'Energy Bar', 'Kurma', 'Roti + PB', 'Protein Shake'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 40000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Hitam/Kuat', 'Kopi Segar/Dingin', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Manis Pas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Air Putih', 'Minuman Sehat/Sejuk', 'Minuman Segar/Elektrolit'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin Segar', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar/Alami', 'Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 33 โ”€โ”€ + [ + _choice('intention', '{name}! Late night makan?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi tengah malam?', ['Kopi', 'Tidak']), + _choice('vibe', 'Late night vibe?', [ + 'Begadang', + 'Lembur', + 'Quality Time', + 'Insomnia', + 'Nonton Film', + ]), + _choice( + 'mealType', + 'Midnight food?', + [ + 'Indomie Goreng', + 'Nasi Goreng 24 Jam', + 'Martabak', + 'Roti Bakar', + 'Bubur 24 Jam', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget midnight?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Pedas malam?', + ['Tidak', 'Biasa', 'Pedas', 'Extra'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Midnight snack?', + ['Chips', 'Popcorn', 'Biskuit', 'Cup Noodle', 'Roti + Selai'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tipe kopi?', + ['Kopi Ringan/Decaf', 'Kopi Susu/Creamy', 'Kopi Hitam/Kuat'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Normal', 'Manis', 'Pahit'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Minuman rileks?', + ['Minuman Susu/Creamy', 'Minuman Hangat/Sehat', 'Air Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Hangat/Panas', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Sedikit Manis', 'Manis Pas', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 34 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Makan tradisional?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi nusantara?', ['Kopi', 'Tidak']), + _choice('region', 'Asal masakan?', [ + 'Jawa', + 'Sumatera', + 'Sulawesi', + 'Kalimantan', + 'Bali/NTT', + ]), + _choice( + 'mealType', + 'Menu tradisional?', + [ + 'Gudeg Jogja', + 'Rendang Padang', + 'Coto Makassar', + 'Soto Banjar', + 'Ayam Betutu', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Bumbu level?', + ['Mild', 'Authentic', 'Extra Rempah', 'Pedas Nusantara'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Jajan tradisional?', + ['Getuk Lindri', 'Nogosari', 'Wajik', 'Jenang', 'Jadah Tempe'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 3000, + max: 25000, + step: 2000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pilihan kopi?', + ['Kopi Hitam/Origin', 'Kopi Susu Tradisional', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula tradisional?', + ['Tanpa Gula', 'Pemanis Alami/Lokal', 'Normal'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Minuman nusantara?', + [ + 'Minuman Hangat/Seduh', + 'Minuman Segar/Es', + 'Minuman Sehat/Tradisional', + ], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Hangat/Panas', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Manis Pas', 'Normal', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 35 โ”€โ”€ + [ + _choice('intention', '{name}! Fusion food hari ini?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Fusion coffee?', ['Kopi', 'Tidak']), + _choice('mood', 'Mood fusion?', [ + 'Creative', + 'Classic', + 'Bold', + 'Curious', + ]), + _choice( + 'mealType', + 'Fusion menu?', + [ + 'Sushi Burrito', + 'Nasi Goreng Kimchi', + 'Rendang Pizza', + 'Satay Taco', + 'Bakso Ramen', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'spice', + 'Pedas fusion?', + ['No', 'Mild', 'Medium', 'Fiery'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Fusion snack?', + [ + 'Taco Rendang', + 'Lumpia Shanghai', + 'Tempura Tempe', + 'Kue Mochi Ubi', + 'Onde Coklat', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Creative coffee?', + ['Kopi Susu Creamy', 'Kopi Hitam/Fusion', 'Kopi Varian Unik'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Sweet level?', + ['Minimal', 'Balanced', 'Sweet', 'Extra Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Creative drink?', + ['Minuman Susu/Fusion', 'Minuman Segar/Modern', 'Minuman Varian Unik'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Manis Pas', 'Extra Sweet', 'Normal'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 36 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Seafood atau daging?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi seafood vibes?', ['Kopi', 'Tidak']), + _choice('preference', 'Seafood atau daging?', [ + 'Seafood', + 'Daging', + 'Campuran', + 'Vegetarian', + ]), + _choice( + 'mealType', + 'Seafood heaven?', + [ + 'Ikan Bakar Jimbaran', + 'Udang Galah', + 'Kepiting Saus Tiram', + 'Cumi Goreng Tepung', + 'Kakap Asam Manis', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget seafood?', + min: 20000, + max: 150000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'cooking', + 'Style masak?', + ['Bakar', 'Goreng', 'Steam', 'Saus', 'Kuah'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Seafood snack?', + [ + 'Kerupuk Udang', + 'Otak-otak', + 'Siomay Ikan', + 'Pempek Kapal Selam', + 'Fish Cake', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 40000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi style?', + ['Kopi Segar/Dingin', 'Kopi Hitam/Manual', 'Kopi Susu/Creamy'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar/Alami', 'Minuman Segar/Manis', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Dingin Pas', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Manis Pas', 'Tawar', 'Sugar Free'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 37 โ”€โ”€ + [ + _choice('intention', '{name}, makan hemat hari ini?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi hemat?', ['Kopi', 'Tidak']), + _choice('budget_mood', 'Budget mode?', [ + 'Super Hemat', + 'Hemat', + 'Normal', + 'Boros Dikit', + ]), + _choice( + 'mealType', + 'Menu hemat?', + [ + 'Nasi Telur', + 'Nasi + Tempe Goreng', + 'Bubur Ayam', + 'Mie Instan', + 'Nasi Warteg', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget irit?', + min: 5000, + max: 25000, + step: 2500, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'portion', + 'Porsi hemat?', + ['Pas-pasan', 'Cukup', 'Nambah Nasi'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Jajan hemat?', + ['Gorengan 1000an', 'Cilok', 'Cireng', 'Pop Mie', 'Roti Sisa'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget jajan?', + min: 2000, + max: 15000, + step: 1000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi hemat?', + ['Kopi Susu/Creamy', 'Kopi Hitam/Manual', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Normal', 'Manis Banget', 'Pahit'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Air Putih', 'Minuman Manis', 'Minuman Segar'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Biasa', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Normal', 'Extra Manis', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 38 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Korean food mood?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Korean coffee?', ['Kopi', 'Tidak']), + _choice('kwave', 'K-drama mood?', [ + 'Romance', + 'Action', + 'Comedy', + 'Thriller', + 'No Drama', + ]), + _choice( + 'mealType', + 'Korean menu?', + ['Bibimbap', 'Tteokbokki', 'Japchae', 'Samgyeopsal', 'Kimchi Jjigae'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget oppa?', + min: 20000, + max: 120000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'spice', + 'Gochujang level?', + ['Mild', 'Medium', 'Spicy', 'Fire Noodle Level'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Korean snack?', + ['Hotteok', 'Bungeoppang', 'Kimbap', 'Corn Dog', 'Tteok'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 60000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tipe kopi?', + ['Kopi Susu/Creamy', 'Kopi Hitam/Kuat', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Kopi Pahit', 'Normal', 'Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Susu/Creamy', 'Minuman Segar', 'Minuman Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Dingin', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Manis Pas', 'Legit', 'Normal'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 39 โ”€โ”€ + [ + _choice('intention', '{name}! Japanese food?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Japanese coffee?', ['Kopi', 'Tidak']), + _choice('mood', 'Zen level?', ['Super Zen', 'Calm', 'Normal', 'Chaotic']), + _choice( + 'mealType', + 'Japanese menu?', + ['Sushi', 'Ramen', 'Udon', 'Katsu Don', 'Teriyaki'], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget?', + min: 20000, + max: 150000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'texture', + 'Texture?', + ['Raw/Fresh', 'Hot Soup', 'Crispy', 'Rice Heavy'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Japanese snack?', + ['Takoyaki', 'Dorayaki', 'Mochi', 'Onigiri', 'Taiyaki'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 60000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tipe kopi?', + ['Kopi Hitam/Manual', 'Kopi Susu/Creamy', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Temperature?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Extra Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Sehat/Sejuk', 'Minuman Segar', 'Minuman Susu/Creamy'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 40 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! BBQ day?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'BBQ coffee?', ['Kopi', 'Tidak']), + _choice('mood', 'Grill mood?', ['Smoky', 'Sweet', 'Spicy', 'All In']), + _choice( + 'mealType', + 'BBQ menu?', + [ + 'Sate Ayam', + 'Sate Kambing', + 'Iga Babi Panggang', + 'Ikan Bakar', + 'Jagung Bakar', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'BBQ budget?', + min: 15000, + max: 100000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'sauce', + 'Saus BBQ?', + ['Kecap Manis', 'Sambal', 'Bumbu Kacang', 'Teriyaki', 'Original'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'BBQ sides?', + [ + 'Jagung Rebus', + 'Singkong Bakar', + 'Pisang Bakar', + 'Roti Bakar', + 'Ubi Bakar', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi teman BBQ?', + ['Kopi Segar/Dingin', 'Kopi Susu/Creamy', 'Kopi Hitam/Kuat'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar', 'Minuman Manis', 'Air Putih/Sehat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Sangat Dingin', 'Dingin Pas', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Extra Manis', 'Normal', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 41 โ”€โ”€ + [ + _choice('intention', '{name}, makan setelah kerja?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'After-work coffee?', ['Kopi', 'Tidak']), + _choice('tired', 'Capek level?', [ + 'Segar', + 'Biasa', + 'Capek', + 'Exhausted', + 'Mau Pingsan', + ]), + _choice( + 'mealType', + 'Reward meal?', + [ + 'Ramen Spesial', + 'Steak', + 'Sushi Set', + 'Nasi Padang', + 'Seafood Platter', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget reward?', + min: 20000, + max: 120000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'dining', + 'Makan di mana?', + ['Dine In', 'Takeaway', 'Delivery', 'Drive Thru'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Snack reward?', + ['Ice Cream', 'Cake Slice', 'Brownies', 'Cheese Fries', 'Wings'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 60000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi santai?', + ['Kopi Ringan/Decaf', 'Kopi Susu/Creamy', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Normal', 'Pas', 'Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Varian', 'Minuman Susu/Creamy', 'Minuman Segar'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Pas', 'Normal', 'Manis Pas'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 42 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Vegetarian hari ini?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Plant-based coffee?', ['Kopi', 'Tidak']), + _choice('plantLevel', 'Seberapa green?', [ + 'Full Vegan', + 'Vegetarian', + 'Flexitarian', + 'Meat Lover', + ]), + _choice( + 'mealType', + 'Veggie menu?', + ['Gado-gado', 'Pecel', 'Nasi Sayur', 'Lotek', 'Buddha Bowl'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'protein', + 'Plant protein?', + ['Tempe', 'Tahu', 'Mushroom', 'Edamame', 'Jackfruit'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Veggie snack?', + [ + 'Keripik Sayur', + 'Tahu Crispy', + 'Tempe Mendoan', + 'Salad Cup', + 'Fruit Bowl', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 40000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi nabati?', + ['Kopi Susu (Nabati)', 'Kopi Hitam/Manual', 'Kopi Varian/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Pemanis?', + ['Pemanis Alami', 'Normal', 'Tanpa Gula'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Susu (Nabati)', 'Minuman Sehat/Sejuk', 'Air Putih/Sehat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Biasa', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Normal', 'Tawar/Alami', 'Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 43 โ”€โ”€ + [ + _choice('intention', '{name}! Makan depan layar?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Coding coffee?', ['Kopi', 'Tidak']), + _choice('screen', 'Lagi ngapain?', [ + 'Kerja/Coding', + 'Netflix', + 'Gaming', + 'Scrolling', + 'Zoom Meeting', + ]), + _choice( + 'mealType', + 'Desk meal?', + ['Rice Bowl', 'Sandwich', 'Wrap', 'Onigiri', 'Bento'], + dep: 'intention', + when: ['Makan'], + ), + _slider('budget', 'Budget?', dep: 'intention', when: ['Makan']), + _choice( + 'ease', + 'Kemudahan makan?', + ['Satu Tangan', 'Sendok/Garpu', 'Sumpit', 'Apapun'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Screen snack?', + ['Popcorn', 'Chips', 'Cookies', 'Nuts', 'Dried Fruit'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi fokus?', + ['Kopi Hitam/Kuat', 'Kopi Hitam/Manual', 'Kopi Segar/Dingin'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Minuman fokus?', + ['Minuman Sehat/Sejuk', 'Minuman Berenergi', 'Air Putih'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 44 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Brunch vibes?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Brunch coffee?', ['Kopi', 'Tidak']), + _choice('brunchMood', 'Brunch style?', [ + 'Classic', + 'Healthy', + 'Indulgent', + 'International', + ]), + _choice( + 'mealType', + 'Brunch menu?', + [ + 'Eggs Benedict', + 'Avocado Toast', + 'Pancake Stack', + 'French Toast', + 'Nasi Goreng Spesial', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget brunch?', + min: 25000, + max: 120000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'side', + 'Side dish?', + ['Salad', 'Fruit Bowl', 'Hash Brown', 'Sausage', 'Yogurt Parfait'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Brunch snack?', + ['Scone', 'Muffin', 'Croissant', 'Danish', 'Brioche'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi brunch?', + ['Kopi Susu/Creamy', 'Kopi Hitam/Manual', 'Kopi Varian/Latte'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Susu?', + ['Susu Nabati', 'Susu Sapi/Creamy', 'Tanpa Susu/Hitam'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas/Hangat', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar', 'Minuman Manis', 'Minuman Susu/Creamy'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Suhu Ruang'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Natural/Natural Sweet', 'Normal', 'Sweet'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 45 โ”€โ”€ + [ + _choice('intention', '{name}! Makan one bowl?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi?', ['Kopi', 'Tidak']), + _choice('bowl', 'Satu mangkuk berisi?', [ + 'Berkuah', + 'Kering', + 'Mixed', + 'Salad', + ]), + _choice( + 'mealType', + 'Bowl menu?', + [ + 'Poke Bowl', + 'Donburi', + 'Burrito Bowl', + 'Aรงaรญ Bowl', + 'Nasi Campur Bowl', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget bowl?', + min: 15000, + max: 80000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'topping', + 'Topping extra?', + ['Telur', 'Avocado', 'Crispy Chicken', 'Salmon', 'Tofu'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Mini bowl?', + [ + 'Granola Bowl', + 'Yogurt Bowl', + 'Smoothie Bowl', + 'Chia Pudding', + 'Oat Bowl', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 50000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi pendamping?', + ['Kopi Susu/Creamy', 'Kopi Hitam/Manual', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis?', + ['Tanpa Gula', 'Normal', 'Sweet'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Sehat/Sejuk', 'Air Putih', 'Minuman Segar/Es'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Dingin Banget', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar', 'Normal', 'Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 46 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Street food tour?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Street coffee?', ['Kopi', 'Tidak']), + _choice('adventure', 'Explore level?', [ + 'Favorit', + 'Baru Coba', + 'Random', + 'Viral/TikTok', + ]), + _choice( + 'mealType', + 'Viral food?', + [ + 'Croffle', + 'Korean Garlic Bread', + 'Cromboloni', + 'Bomboloni', + 'Dirty Boba Rice', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget viral?', + min: 10000, + max: 80000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'hype', + 'Worth the hype?', + ['Coba Dulu', 'FOMO', 'Penasaran', 'Udah Coba Suka'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Viral snack?', + [ + 'Boba Bread', + 'Cloud Cake', + 'Burnt Cheesecake', + 'Egg Drop', + 'UFO Cake', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 60000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi kekinian?', + ['Kopi Varian/Flavor', 'Kopi Susu (Creamy)', 'Kopi Hitam/Segar'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Normal', 'Extra Manis', 'Less Sugar'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Susu/Manis', 'Minuman Segar/Modern', 'Minuman Varian Teh'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin Banget', 'Dingin', 'Biasa'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Manis Pas', 'Legit/Sweet', 'Normal'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 47 โ”€โ”€ + [ + _choice('intention', '{name}, makan apa setelah puasa?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Kopi berbuka?', ['Kopi', 'Tidak']), + _choice('hunger', 'Level lapar?', [ + 'Biasa', + 'Lapar', + 'Sangat Lapar', + 'Kelaparan Parah', + ]), + _choice( + 'mealType', + 'Buka puasa menu?', + [ + 'Nasi + Lauk Lengkap', + 'Soto', + 'Bakso Urat', + 'Nasi Kebuli', + 'Prasmanan', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget buka?', + min: 10000, + max: 80000, + step: 5000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'portion', + 'Porsi buka?', + ['Sedang', 'Banyak', 'Super Banyak', 'All You Can Eat'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Takjil?', + ['Kurma', 'Kolak', 'Es Buah', 'Gorengan', 'Bubur Sumsum'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget takjil?', + min: 5000, + max: 30000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi berbuka?', + ['Kopi Susu (Creamy)', 'Kopi Hitam/Manual', 'Kopi Varian/Flavor'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Manis berbuka?', + ['Manis Pas', 'Normal', 'Pahit'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Dingin/Es', 'Panas/Hangat'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Segar/Es', 'Minuman Alami/Segar', 'Minuman Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin/Es', 'Sangat Dingin', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Manis Legit', 'Normal', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 48 โ”€โ”€ + [ + _choice('intention', 'Halo {name}! Dessert day!', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Dessert coffee?', ['Kopi', 'Tidak']), + _choice('sweet', 'Sweet tooth level?', [ + 'Sedikit Manis', + 'Normal', + 'Sweet Lover', + 'Diabetes Mode', + ]), + _choice( + 'mealType', + 'Main + dessert?', + [ + 'Pasta + Gelato', + 'Steak + Cake', + 'Ramen + Mochi', + 'Pizza + Ice Cream', + 'Burger + Milkshake', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget?', + min: 25000, + max: 150000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'dessert', + 'Dessert pilihan?', + ['Cake', 'Ice Cream', 'Pudding', 'Pie', 'Crepe'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Pure dessert?', + ['Cheesecake', 'Tiramisu', 'Crรจme Brรปlรฉe', 'Profiteroles', 'Pavlova'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget dessert?', + min: 15000, + max: 80000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Pasangan dessert?', + ['Kopi Hitam/Pahit', 'Kopi Varian/Manis', 'Kopi Susu (Creamy)'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Level manis?', + ['Kontras/Pahit', 'Pas/Balanced', 'Matching/Manis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin/Es'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Susu/Manis', 'Minuman Varian', 'Minuman Susu/Creamy'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin Banget', 'Dingin', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Sweet/Legit', 'Normal', 'Extra Sweet'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 49 โ”€โ”€ + [ + _choice('intention', '{name}! Makan kenyang?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'After meal coffee?', ['Kopi', 'Tidak']), + _choice('satisfaction', 'Target kenyang?', [ + 'Cukup', + 'Puas', + 'Food Coma', + 'Full Maximum', + ]), + _choice( + 'mealType', + 'All in meal?', + [ + 'Nasi Padang Komplit', + 'Set Meal Jepang', + 'Nasi Campur Bali', + 'Rijsttafel', + 'Family Set Chinese', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Budget all-in?', + min: 25000, + max: 200000, + step: 10000, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'side', + 'Extra side?', + ['Tidak', 'Sup', 'Salad', 'Gorengan', 'Sambal Extra'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Heavy snack?', + [ + 'Martabak Manis', + 'Roti Bakar', + 'Pisang Bakar Keju', + 'Waffle', + 'French Toast', + ], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 10000, + max: 60000, + step: 5000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Kopi penutup?', + ['Kopi Hitam/Kuat', 'Kopi Hitam/Manual', 'Kopi Susu (Creamy)'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Tanpa Gula', 'Normal', 'Pas'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Minuman Teh/Segar', 'Air Putih/Hangat', 'Minuman Hangat/Sehat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Hangat/Panas', 'Dingin', 'Normal'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Tawar/Plain', 'Normal', 'Sedikit Manis'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + + // โ”€โ”€ SET 50 โ”€โ”€ + [ + _choice('intention', 'Hai {name}! Akhir bulan, makan apa?', [ + 'Makan', + 'Ngemil', + 'Tidak Sama Sekali', + ]), + _choice('coffee', 'Last budget coffee?', ['Kopi', 'Tidak']), + _choice('survival', 'Survival mode?', [ + 'Masih Aman', + 'Agak Tipis', + 'Hemat Banget', + 'Top Up Dulu', + ]), + _choice( + 'mealType', + 'Survival meal?', + [ + 'Nasi + Telur Ceplok', + 'Indomie', + 'Nasi Warteg Hemat', + 'Bubur Ayam', + 'Gado-gado', + ], + dep: 'intention', + when: ['Makan'], + ), + _slider( + 'budget', + 'Sisa budget?', + min: 5000, + max: 30000, + step: 2500, + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'portion', + 'Porsi akhir bulan?', + ['Secukupnya', 'Nambah Nasi', 'Double'], + dep: 'intention', + when: ['Makan'], + ), + _choice( + 'snackType', + 'Jajan hemat?', + ['Gorengan', 'Roti Tawar', 'Biskuit', 'Pop Mie', 'Cireng'], + dep: 'intention', + when: ['Ngemil'], + ), + _slider( + 'snackBudget', + 'Budget?', + min: 2000, + max: 15000, + step: 1000, + dep: 'intention', + when: ['Ngemil'], + ), + _choice( + 'coffeeType', + 'Tetap ngopi?', + ['Kopi Varian/Sachet', 'Kopi Hitam/Murah', 'Kopi Susu/Ekonomis'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeTemp', + 'Suhu?', + ['Panas', 'Dingin'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'coffeeSweetness', + 'Gula?', + ['Banyak', 'Normal', 'Pahit'], + dep: 'coffee', + when: ['Kopi'], + ), + _choice( + 'drinkType', + 'Jenis minuman?', + ['Air Putih', 'Minuman Segar/Es', 'Minuman Manis/Segar'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkTemp', + 'Suhu?', + ['Dingin', 'Biasa', 'Hangat'], + dep: 'coffee', + when: ['Tidak'], + ), + _choice( + 'drinkSweetness', + 'Rasa?', + ['Extra Manis', 'Normal', 'Tawar'], + dep: 'coffee', + when: ['Tidak'], + ), + ], + ]; +} diff --git a/lib/services/dialog_service.dart b/lib/services/dialog_service.dart new file mode 100644 index 0000000..282e3f0 --- /dev/null +++ b/lib/services/dialog_service.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import '../core/theme/app_theme.dart'; + +enum NotificationType { success, error, warning, info } + +class DialogService { + // Singleton pattern + static final DialogService _instance = DialogService._internal(); + + factory DialogService() { + return _instance; + } + + DialogService._internal(); + + // Global keys + final GlobalKey scaffoldMessengerKey = + GlobalKey(); + + final GlobalKey navigatorKey = GlobalKey(); + + // Helper method to get context (use with caution, better to pass context or use keys) + BuildContext? get _context => navigatorKey.currentContext; + + // --- SnackBar Notifications --- + + void showSnackBar({ + required String message, + NotificationType type = NotificationType.info, + Duration duration = const Duration(seconds: 3), + String? actionLabel, + VoidCallback? onAction, + }) { + if (scaffoldMessengerKey.currentState == null) { + debugPrint( + 'โš ๏ธ ScaffoldMessengerState is null, cannot show SnackBar: $message', + ); + return; + } + + Color backgroundColor; + Color textColor = Colors.white; + IconData icon; + + switch (type) { + case NotificationType.success: + backgroundColor = AppTheme.success; + icon = Icons.check_circle_outline; + break; + case NotificationType.error: + backgroundColor = AppTheme.error.withOpacity(0.9); + icon = Icons.error_outline; + break; + case NotificationType.warning: + backgroundColor = AppTheme.warning; + textColor = Colors.black87; // Dark text for yellow background + icon = Icons.warning_amber_rounded; + break; + case NotificationType.info: + backgroundColor = AppTheme.primaryBrown; + icon = Icons.info_outline; + break; + } + + scaffoldMessengerKey.currentState!.removeCurrentSnackBar(); + scaffoldMessengerKey.currentState!.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(icon, color: textColor, size: 24), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w500, + fontFamily: 'Poppins', + ), + ), + ), + ], + ), + backgroundColor: backgroundColor, + duration: duration, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + action: actionLabel != null + ? SnackBarAction( + label: actionLabel, + textColor: textColor, + onPressed: onAction ?? () {}, + ) + : null, + ), + ); + } + + void showSuccess(String message) { + showSnackBar(message: message, type: NotificationType.success); + } + + void showError(String message) { + showSnackBar(message: message, type: NotificationType.error); + } + + void showWarning(String message) { + showSnackBar(message: message, type: NotificationType.warning); + } + + void showInfo(String message) { + showSnackBar(message: message, type: NotificationType.info); + } + + // --- Dialogs --- + + Future showAlertDialog({ + required String title, + required String message, + String buttonText = 'OK', + BuildContext? context, + }) async { + final ctx = context ?? _context; + if (ctx == null) return; + + return showDialog( + context: ctx, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + content: Text(message), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actions: [ + TextButton( + child: Text(buttonText), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ); + }, + ); + } + + Future showConfirmDialog({ + required String title, + required String message, + String confirmText = 'Ya', + String cancelText = 'Batal', + bool isDanger = false, + BuildContext? context, + }) async { + final ctx = context ?? _context; + if (ctx == null) return false; + + return await showDialog( + context: ctx, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + content: Text(message), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actions: [ + TextButton( + child: Text( + cancelText, + style: const TextStyle(color: Colors.grey), + ), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + TextButton( + child: Text( + confirmText, + style: TextStyle( + color: isDanger ? AppTheme.error : AppTheme.primaryBrown, + fontWeight: FontWeight.bold, + ), + ), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + ); + }, + ) ?? + false; + } + + Future showLoadingDialog({ + String message = 'Memuat...', + BuildContext? context, + }) async { + final ctx = context ?? _context; + if (ctx == null) return; + + showDialog( + context: ctx, + barrierDismissible: false, + builder: (BuildContext context) { + return PopScope( + canPop: false, // Prevent back button + child: Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: AppTheme.primaryBrown), + const SizedBox(height: 20), + Text( + message, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + void hideLoadingDialog({BuildContext? context}) { + final ctx = context ?? _context; + if (ctx == null) { + // If we don't have context but assume the navigator is attached + if (navigatorKey.currentState?.canPop() ?? false) { + navigatorKey.currentState?.pop(); + } + return; + } + + Navigator.of(ctx).pop(); + } +} diff --git a/lib/services/emailjs_service.dart b/lib/services/emailjs_service.dart new file mode 100644 index 0000000..3e7f7cc --- /dev/null +++ b/lib/services/emailjs_service.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +/// Service for sending OTP emails via SMTP API +class EmailJSService { + // Gmail SMTP Configuration + static const String _senderEmail = 'erdiseptawahyupratamaggwp@gmail.com'; + static const String _appPassword = 'bcwgpewcqxcyhszp'; + static const String _apiUrl = 'https://freeemailapi.vercel.app/sendEmail/'; + + // Store OTP codes temporarily + static final Map _otpStore = {}; + static String? lastGeneratedOTP; + + /// Generate a random 5-digit OTP + static String generateOTP() { + final random = Random(); + return (10000 + random.nextInt(90000)).toString(); + } + + /// Send OTP to email + static Future sendOTP(String email) async { + try { + final otp = generateOTP(); + final expiresAt = DateTime.now().add(const Duration(minutes: 5)); + + _otpStore[email] = _OTPData(otp: otp, expiresAt: expiresAt); + lastGeneratedOTP = otp; + + debugPrint('๐Ÿ“ค Sending OTP to $email...'); + + final response = await http.post( + Uri.parse(_apiUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'fromEmail': _senderEmail, + 'passkey': _appPassword, + 'toEmail': email, + 'title': 'Amori Assistant', + 'subject': 'Kode Verifikasi OTP', + 'body': + 'Kode verifikasi Anda adalah: $otp\n\n' + 'Kode ini berlaku selama 5 menit.\n' + 'Jika Anda tidak meminta kode ini, abaikan email ini.', + }), + ); + + final data = jsonDecode(response.body); + final message = data['message']?.toString() ?? ''; + + debugPrint('๐Ÿ“ฅ Email API Response: $message'); + + if (message == 'emailSendSuccess') { + debugPrint('โœ… OTP sent successfully to $email'); + return true; + } else { + debugPrint('โŒ Failed to send OTP: $message'); + return false; + } + } catch (e) { + debugPrint('โŒ Error sending OTP: $e'); + return false; + } + } + + /// Verify OTP code + static bool verifyOTP(String email, String code) { + final otpData = _otpStore[email]; + + if (otpData == null) { + debugPrint('โŒ No OTP found for $email'); + return false; + } + + if (DateTime.now().isAfter(otpData.expiresAt)) { + debugPrint('โŒ OTP expired for $email'); + _otpStore.remove(email); + return false; + } + + if (otpData.otp != code) { + debugPrint( + 'โŒ Invalid OTP for $email (expected: ${otpData.otp}, got: $code)', + ); + return false; + } + + debugPrint('โœ… OTP verified for $email'); + _otpStore.remove(email); + return true; + } + + /// Check if OTP exists and is valid (not expired) + static bool hasValidOTP(String email) { + final otpData = _otpStore[email]; + if (otpData == null) return false; + return DateTime.now().isBefore(otpData.expiresAt); + } + + /// Clear OTP for email + static void clearOTP(String email) { + _otpStore.remove(email); + } + + /// Get last generated OTP (for dev mode display) + static String? getLastOTP() => lastGeneratedOTP; +} + +class _OTPData { + final String otp; + final DateTime expiresAt; + + _OTPData({required this.otp, required this.expiresAt}); +} diff --git a/lib/services/firebase_service.dart b/lib/services/firebase_service.dart new file mode 100644 index 0000000..8a43f59 --- /dev/null +++ b/lib/services/firebase_service.dart @@ -0,0 +1,549 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import '../models/user_model.dart'; +import '../models/recommendation_model.dart'; +import '../core/constants/app_constants.dart'; +import 'google_signin_wrapper.dart'; + +class FirebaseService { + static final FirebaseService _instance = FirebaseService._internal(); + factory FirebaseService() => _instance; + + // Firebase instances + final FirebaseAuth _auth = FirebaseAuth.instance; + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseMessaging _messaging = FirebaseMessaging.instance; + + // Initialize Firebase Service + FirebaseService._internal(); + + // Current user stream + Stream get authStateChanges => _auth.authStateChanges(); + User? get currentUser => _auth.currentUser; + bool get isSignedIn => currentUser != null; + + // Initialize Firebase + static Future initialize() async { + try { + await Firebase.initializeApp(); + debugPrint('Firebase initialized successfully'); + } catch (e) { + debugPrint('Firebase initialization error: $e'); + rethrow; + } + } + + // Authentication Methods + Future signInWithEmail(String email, String password) async { + try { + final credential = await _auth.signInWithEmailAndPassword( + email: email, + password: password, + ); + return credential; + } on FirebaseAuthException catch (e) { + debugPrint('Sign in error: ${e.message}'); + throw _handleAuthException(e); + } + } + + Future registerWithEmail( + String email, + String password, + ) async { + try { + final credential = await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + + // Create user document in Firestore + if (credential.user != null) { + await createUserDocument(credential.user!); + } + + return credential; + } on FirebaseAuthException catch (e) { + debugPrint('Registration error: ${e.message}'); + throw _handleAuthException(e); + } + } + + Future signInWithGoogle() async { + try { + debugPrint('๐Ÿ”„ Starting safe Google Sign-In process...'); + + // Use the crash-safe wrapper + final googleSignInWrapper = GoogleSignInWrapper.instance; + + // Check if Google Sign-In is available + bool isAvailable = await googleSignInWrapper.isAvailable(); + if (!isAvailable) { + throw Exception('Google Sign-In is not available on this device'); + } + + // Attempt sign-in using the wrapper + UserCredential? userCredential = await googleSignInWrapper.signIn(); + + if (userCredential == null) { + debugPrint('๐Ÿ‘ค User cancelled Google Sign-In'); + return null; + } + + return userCredential; + } catch (e) { + debugPrint('โŒ Google Sign-In failed: $e'); + throw Exception('Google Sign-In failed: $e'); + } + } + + Future signOut() async { + try { + await Future.wait([ + _auth.signOut(), + GoogleSignInWrapper.instance.signOut(), + ]); + } catch (e) { + debugPrint('Sign out error: $e'); + throw Exception('Sign out failed: $e'); + } + } + + Future resetPassword(String email) async { + try { + await _auth.sendPasswordResetEmail(email: email); + } on FirebaseAuthException catch (e) { + debugPrint('Password reset error: ${e.message}'); + throw _handleAuthException(e); + } + } + + /// Check if email exists in Firestore (registered user) + Future checkEmailExists(String email) async { + try { + final querySnapshot = await _firestore + .collection(AppConstants.usersCollection) + .where('email', isEqualTo: email) + .limit(1) + .get(); + + return querySnapshot.docs.isNotEmpty; + } catch (e) { + debugPrint('Check email exists error: $e'); + return false; + } + } + + // Email Verification Methods + Future createUserForVerification( + String email, + String tempPassword, + ) async { + try { + final credential = await _auth.createUserWithEmailAndPassword( + email: email, + password: tempPassword, + ); + return credential; + } on FirebaseAuthException catch (e) { + debugPrint('Create user for verification error: ${e.message}'); + throw _handleAuthException(e); + } + } + + Future sendEmailVerification() async { + try { + final user = _auth.currentUser; + if (user != null && !user.emailVerified) { + await user.sendEmailVerification(); + debugPrint('๐Ÿ“ง Verification email sent to ${user.email}'); + } + } catch (e) { + debugPrint('Send email verification error: $e'); + throw Exception('Gagal mengirim email verifikasi: $e'); + } + } + + Future checkEmailVerified() async { + try { + await _auth.currentUser?.reload(); + return _auth.currentUser?.emailVerified ?? false; + } catch (e) { + debugPrint('Check email verified error: $e'); + return false; + } + } + + Future updatePassword(String newPassword) async { + try { + final user = _auth.currentUser; + if (user != null) { + await user.updatePassword(newPassword); + debugPrint('๐Ÿ”‘ Password updated successfully'); + } + } catch (e) { + debugPrint('Update password error: $e'); + throw Exception('Gagal memperbarui password: $e'); + } + } + + /// Link email/password to existing account (for Google users) + Future linkEmailPassword( + String email, + String password, + ) async { + try { + final user = _auth.currentUser; + if (user == null) { + throw Exception('User tidak ditemukan'); + } + + // Create email/password credential + final credential = EmailAuthProvider.credential( + email: email, + password: password, + ); + + // Link the credential to the current user + final result = await user.linkWithCredential(credential); + debugPrint('โœ… Email/password linked successfully to ${user.email}'); + return result; + } on FirebaseAuthException catch (e) { + debugPrint('Link email/password error: ${e.code} - ${e.message}'); + if (e.code == 'provider-already-linked') { + debugPrint('โš ๏ธ Provider already linked, updating password to match new input...'); + try { + await _auth.currentUser?.updatePassword(password); + return null; + } catch (err) { + debugPrint('Failed to update password on already linked account: $err'); + } + } else if (e.code == 'credential-already-in-use') { + throw Exception('Email ini sudah digunakan akun lain'); + } + throw _handleAuthException(e); + } catch (e) { + debugPrint('Link email/password error: $e'); + throw Exception('Gagal menghubungkan email/password: $e'); + } + } + + // User Document Methods + Future createUserDocument( + User user, { + String? name, + String? phone, + }) async { + try { + final userDoc = _firestore + .collection(AppConstants.usersCollection) + .doc(user.uid); + // Force create/merge user document to ensure it exists + // This is crucial for self-healing if data was deleted + final userModel = UserModel( + id: user.uid, + email: user.email!, + displayName: name ?? user.displayName, + phoneNumber: phone, + photoUrl: user.photoURL, + preferences: UserPreferences(), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + isSetupComplete: false, + ); + + // Use SetOptions(merge: true) to safe-write + await userDoc.set(userModel.toJson(), SetOptions(merge: true)); + debugPrint('โœ… User document created/merged for ${user.uid}'); + + return userModel; + } catch (e) { + debugPrint('Create user document error: $e'); + throw Exception('Failed to create user document: $e'); + } + } + + Future isUserRegistered(String uid) async { + try { + final doc = await _firestore + .collection(AppConstants.usersCollection) + .doc(uid) + .get(); + return doc.exists; + } catch (e) { + return false; + } + } + + Future getUserData(String userId) async { + try { + final doc = await _firestore + .collection(AppConstants.usersCollection) + .doc(userId) + .get(); + if (doc.exists && doc.data() != null) { + return UserModel.fromJson(doc.data()!); + } + return null; + } catch (e) { + debugPrint('Get user data error: $e'); + throw Exception('Failed to get user data: $e'); + } + } + + /// Get user data by email address + Future getUserDataByEmail(String email) async { + try { + final querySnapshot = await _firestore + .collection(AppConstants.usersCollection) + .where('email', isEqualTo: email) + .limit(1) + .get(); + + if (querySnapshot.docs.isNotEmpty) { + return UserModel.fromJson(querySnapshot.docs.first.data()); + } + return null; + } catch (e) { + debugPrint('Get user data by email error: $e'); + return null; + } + } + + Future updateUserData(UserModel user) async { + try { + await _firestore + .collection(AppConstants.usersCollection) + .doc(user.id) + .update(user.copyWith(updatedAt: DateTime.now()).toJson()); + } catch (e) { + debugPrint('Update user data error: $e'); + throw Exception('Failed to update user data: $e'); + } + } + + Future updateUserPreferences( + String userId, + UserPreferences preferences, + ) async { + try { + final doc = await _firestore + .collection(AppConstants.usersCollection) + .doc(userId) + .get(); + + if (!doc.exists) throw Exception('User not found'); + + final currentData = doc.data() as Map; + final dailyPreferences = Map.from( + currentData['dailyPreferences'] ?? {}); + + // Sinkronisasi Balik: Profil -> Daily Check-in (keinginan) + bool hasFood = preferences.favoriteCategories.any((c) => + c.toLowerCase().contains('makanan') || + c.toLowerCase().contains('snack') || + c.toLowerCase().contains('dessert') || + c.toLowerCase().contains('pastry') || + c.toLowerCase().contains('sarapan') || + c.toLowerCase().contains('berkuah')); + + bool hasDrink = preferences.favoriteCategories.any((c) => + c.toLowerCase().contains('minuman')); + + if (hasFood && hasDrink) { + dailyPreferences['keinginan'] = 'keduanya'; + } else if (hasDrink && !hasFood) { + dailyPreferences['keinginan'] = 'minuman'; + } else if (hasFood && !hasDrink) { + dailyPreferences['keinginan'] = 'makanan'; + } + + // Sinkronkan juga budget dari profil ke daily checkin hari ini + dailyPreferences['budget'] = preferences.budgetRange; + + final data = preferences.toJson(); + + // ๐Ÿ” DEBUG โ€” hapus setelah debugging selesai + debugPrint('=== SAVE PREFERENCES DEBUG ==='); + debugPrint('userId: $userId'); + debugPrint('favoriteCategories: ${preferences.favoriteCategories}'); + debugPrint('new keinginan: ${dailyPreferences['keinginan']}'); + debugPrint('=============================='); + + await _firestore + .collection(AppConstants.usersCollection) + .doc(userId) + .update({ + 'preferences': data, + 'dailyPreferences': dailyPreferences, + 'updatedAt': DateTime.now().toIso8601String(), + }); + debugPrint('โœ… Preferences saved successfully (and synced to dailyPreferences)'); + } catch (e) { + debugPrint('Update user preferences error: $e'); + throw Exception('Failed to update user preferences: $e'); + } + } + + Future setSetupComplete(String userId) async { + try { + await _firestore + .collection(AppConstants.usersCollection) + .doc(userId) + .update({ + 'isSetupComplete': true, + 'updatedAt': DateTime.now().toIso8601String(), + }); + } catch (e) { + debugPrint('Set setup complete error: $e'); + throw Exception('Failed to set setup complete: $e'); + } + } + + // Recommendations Methods + Future saveRecommendation(RecommendationModel recommendation) async { + try { + await _firestore + .collection(AppConstants.recommendationsCollection) + .doc(recommendation.id) + .set(recommendation.toJson()); + } catch (e) { + debugPrint('Save recommendation error: $e'); + throw Exception('Failed to save recommendation: $e'); + } + } + + Future> getUserRecommendations( + String userId, { + int limit = 20, + }) async { + try { + // Simplified query to avoid index issues - just get by userId first + final query = await _firestore + .collection(AppConstants.recommendationsCollection) + .where('userId', isEqualTo: userId) + .limit(limit) + .get(); + + // Sort in memory to avoid composite index requirement + final recommendations = query.docs + .map((doc) => RecommendationModel.fromJson(doc.data())) + .toList(); + + // Sort by createdAt in descending order + recommendations.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + return recommendations; + } catch (e) { + debugPrint('Get user recommendations error: $e'); + // Return empty list instead of throwing exception for better UX + return []; + } + } + + Future toggleRecommendationFavorite( + String recommendationId, + bool isFavorite, + ) async { + try { + await _firestore + .collection(AppConstants.recommendationsCollection) + .doc(recommendationId) + .update({'isFavorite': isFavorite}); + } catch (e) { + debugPrint('Toggle recommendation favorite error: $e'); + throw Exception('Failed to update recommendation: $e'); + } + } + + Future deleteRecommendation(String recommendationId) async { + try { + await _firestore + .collection(AppConstants.recommendationsCollection) + .doc(recommendationId) + .delete(); + } catch (e) { + debugPrint('Delete recommendation error: $e'); + throw Exception('Failed to delete recommendation: $e'); + } + } + + // Push Notifications + Future initializeNotifications() async { + try { + // Request permission + NotificationSettings settings = await _messaging.requestPermission( + alert: true, + announcement: false, + badge: true, + carPlay: false, + criticalAlert: false, + provisional: false, + sound: true, + ); + + if (settings.authorizationStatus == AuthorizationStatus.authorized) { + debugPrint('User granted permission for notifications'); + + // Get FCM token + String? token = await _messaging.getToken(); + if (token != null && currentUser != null) { + await _saveFCMToken(currentUser!.uid, token); + } + + // Listen for token refresh + _messaging.onTokenRefresh.listen((token) { + if (currentUser != null) { + _saveFCMToken(currentUser!.uid, token); + } + }); + } + } catch (e) { + debugPrint('Initialize notifications error: $e'); + } + } + + Future _saveFCMToken(String userId, String token) async { + try { + await _firestore + .collection(AppConstants.usersCollection) + .doc(userId) + .update({ + 'fcmToken': token, + 'updatedAt': DateTime.now().toIso8601String(), + }); + } catch (e) { + debugPrint('Save FCM token error: $e'); + } + } + + // Error handling + String _handleAuthException(FirebaseAuthException e) { + switch (e.code) { + case 'invalid-credential': + return 'Email atau Password salah'; + case 'user-not-found': + return 'Pengguna tidak ditemukan'; + case 'wrong-password': + return 'Password salah'; + case 'email-already-in-use': + return 'Email sudah digunakan'; + case 'weak-password': + return 'Password terlalu lemah'; + case 'invalid-email': + return 'Format email tidak valid'; + case 'user-disabled': + return 'Akun telah dinonaktifkan'; + case 'too-many-requests': + return 'Terlalu banyak percobaan, coba lagi nanti'; + case 'operation-not-allowed': + return 'Operasi tidak diizinkan'; + default: + return e.message ?? 'Terjadi kesalahan'; + } + } +} + diff --git a/lib/services/fonnte_service.dart b/lib/services/fonnte_service.dart new file mode 100644 index 0000000..4c8feac --- /dev/null +++ b/lib/services/fonnte_service.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; + +/// Service for sending OTP via WhatsApp using Fonnte API +class FonnteService { + // Fonnte Configuration + static const String _apiUrl = 'https://api.fonnte.com/send'; + static const String _token = 'bXMkTXeLhVbmXmh6mYG5'; + static const String _countryCode = '62'; // Indonesia + + // Store OTP codes temporarily (in production, use backend) + static final Map _otpStore = {}; + + // Store last generated OTP for dev mode display + static String? lastGeneratedOTP; + + /// Generate a random 5-digit OTP + static String generateOTP() { + final random = Random(); + return (10000 + random.nextInt(90000)).toString(); + } + + /// Format phone number for Fonnte API + /// Converts 08xx to 628xx format + static String formatPhoneNumber(String phone) { + phone = phone.replaceAll(RegExp(r'[^0-9]'), ''); // Remove non-digits + if (phone.startsWith('0')) { + phone = '62${phone.substring(1)}'; + } else if (!phone.startsWith('62')) { + phone = '62$phone'; + } + return phone; + } + + /// Send OTP to WhatsApp number via Fonnte + static Future sendOTP(String phoneNumber) async { + try { + final otp = generateOTP(); + final expiresAt = DateTime.now().add(const Duration(minutes: 5)); + final formattedPhone = formatPhoneNumber(phoneNumber); + + // Store OTP locally (for verification) + _otpStore[formattedPhone] = _OTPData(otp: otp, expiresAt: expiresAt); + // Also store with original number for flexible verification + _otpStore[phoneNumber] = _OTPData(otp: otp, expiresAt: expiresAt); + lastGeneratedOTP = otp; + + // Log for debugging purposes + debugPrint('๐Ÿ“ค Sending WhatsApp OTP to $formattedPhone via Fonnte...'); + + // Send WhatsApp message via Fonnte API + final message = + '๐Ÿ” *Kode Verifikasi AMORI Assistant*\n\n' + 'Kode OTP Anda: *$otp*\n\n' + 'Kode ini berlaku selama 5 menit.\n' + 'Jangan bagikan kode ini kepada siapapun.'; + + final response = await http.post( + Uri.parse(_apiUrl), + headers: {'Authorization': _token}, + body: { + 'target': formattedPhone, + 'message': message, + 'countryCode': _countryCode, + }, + ); + + debugPrint('๐Ÿ“ฅ Fonnte Response: ${response.statusCode} ${response.body}'); + + final responseData = jsonDecode(response.body); + + if (responseData['status'] == true) { + debugPrint('โœ… WhatsApp OTP sent successfully to $formattedPhone'); + return true; + } else { + debugPrint( + 'โŒ Fonnte API error: ${responseData['reason'] ?? responseData['detail']}', + ); + return false; + } + } catch (e) { + debugPrint('โŒ Error sending WhatsApp OTP: $e'); + return false; + } + } + + /// Verify OTP code + static bool verifyOTP(String phoneNumber, String code) { + final formattedPhone = formatPhoneNumber(phoneNumber); + + // Try both formatted and original number + final otpData = _otpStore[formattedPhone] ?? _otpStore[phoneNumber]; + + if (otpData == null) { + debugPrint('โŒ No OTP found for $phoneNumber'); + return false; + } + + if (DateTime.now().isAfter(otpData.expiresAt)) { + debugPrint('โŒ OTP expired for $phoneNumber'); + _otpStore.remove(formattedPhone); + _otpStore.remove(phoneNumber); + return false; + } + + if (otpData.otp != code) { + debugPrint( + 'โŒ Invalid OTP for $phoneNumber (expected: ${otpData.otp}, got: $code)', + ); + return false; + } + + debugPrint('โœ… WhatsApp OTP verified for $phoneNumber'); + _otpStore.remove(formattedPhone); + _otpStore.remove(phoneNumber); + return true; + } + + /// Check if OTP exists and is valid (not expired) + static bool hasValidOTP(String phoneNumber) { + final formattedPhone = formatPhoneNumber(phoneNumber); + final otpData = _otpStore[formattedPhone] ?? _otpStore[phoneNumber]; + if (otpData == null) return false; + return DateTime.now().isBefore(otpData.expiresAt); + } + + /// Clear OTP for phone + static void clearOTP(String phoneNumber) { + final formattedPhone = formatPhoneNumber(phoneNumber); + _otpStore.remove(formattedPhone); + _otpStore.remove(phoneNumber); + } + + /// Get last generated OTP (for dev mode display) + static String? getLastOTP() => lastGeneratedOTP; +} + +class _OTPData { + final String otp; + final DateTime expiresAt; + + _OTPData({required this.otp, required this.expiresAt}); +} diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart new file mode 100644 index 0000000..d713512 --- /dev/null +++ b/lib/services/gemini_service.dart @@ -0,0 +1,1233 @@ +import 'package:flutter/foundation.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/user_model.dart'; +import '../models/recommendation_model.dart'; +import '../models/daily_question_model.dart'; +import '../services/ocr_service.dart'; +import '../core/config/app_config.dart'; +import 'dart:convert'; +import '../services/firebase_service.dart'; + +class GeminiService { + static final GeminiService _instance = GeminiService._internal(); + factory GeminiService() => _instance; + GeminiService._internal(); + + late final GenerativeModel _model; + late final GenerativeModel _chatModel; + + bool _isInitialized = false; + String? _currentApiKey; + static const String _apiKeyStorageKey = 'user_gemini_api_key'; + + // Load saved user API key + Future _loadSavedApiKey() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_apiKeyStorageKey); + } catch (e) { + debugPrint('Error loading saved API key: $e'); + return null; + } + } + + // Save user API key + Future _saveApiKey(String apiKey) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_apiKeyStorageKey, apiKey); + } catch (e) { + debugPrint('Error saving API key: $e'); + } + } + + // Remove saved API key + Future _removeSavedApiKey() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_apiKeyStorageKey); + } catch (e) { + debugPrint('Error removing saved API key: $e'); + } + } + + // Initialize Gemini service with user's API key or fallback + Future initialize({String? userApiKey}) async { + try { + // Priority: userApiKey parameter > saved user API key > app default API key + String? apiKey = + userApiKey ?? await _loadSavedApiKey() ?? AppConfig.geminiApiKey; + _currentApiKey = apiKey; + + if (apiKey.isEmpty) { + throw Exception( + 'No Gemini API key available. Please provide your own API key.', + ); + } + + debugPrint('๐Ÿ”„ Initializing with Gemini 2.0 Flash...'); + + _model = GenerativeModel( + model: 'gemini-3-flash-preview', + apiKey: apiKey, + generationConfig: GenerationConfig( + temperature: AppConfig.aiTemperature, + topK: 64, + topP: 0.95, + maxOutputTokens: AppConfig.maxTokensPerRequest, + ), + safetySettings: [ + SafetySetting(HarmCategory.harassment, HarmBlockThreshold.medium), + SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.medium), + SafetySetting( + HarmCategory.sexuallyExplicit, + HarmBlockThreshold.medium, + ), + SafetySetting( + HarmCategory.dangerousContent, + HarmBlockThreshold.medium, + ), + ], + ); + + _chatModel = GenerativeModel( + model: 'gemini-3-flash-preview', + apiKey: apiKey, + generationConfig: GenerationConfig( + temperature: 0.8, + topK: 40, + topP: 0.95, + maxOutputTokens: 2048, + ), + tools: [ + Tool( + functionDeclarations: [ + FunctionDeclaration( + 'toggle_menu_favorite', + 'Tambahkan atau hapus item menu dari daftar favorit berdasarkan namanya.', + Schema.object( + properties: { + 'menuName': Schema.string( + description: + 'Nama menu yang ingin diubah status favoritnya.', + ), + 'isFavorite': Schema.boolean( + description: + 'Status favorit baru (true untuk favorit, false untuk tidak).', + ), + }, + requiredProperties: ['menuName', 'isFavorite'], + ), + ), + FunctionDeclaration( + 'recommend_specific_drink', + 'Memberikan rekomendasi minuman spesifik berdasarkan mood atau cuaca.', + Schema.object( + properties: { + 'drinkType': Schema.string( + description: 'Jenis minuman (kopi, teh, non-kopi).', + ), + 'isIce': Schema.boolean( + description: 'Apakah harus disajikan dingin.', + ), + }, + requiredProperties: ['drinkType'], + ), + ), + ], + ), + ], + ); + + _isInitialized = true; + debugPrint('โœ… Gemini 3 Flash Preview initialized successfully'); + } catch (e) { + debugPrint('โŒ Gemini initialization error: $e'); + throw Exception('Failed to initialize Gemini service: $e'); + } + } + + /// Standalone chat tentang menu Amor Coffee โ€” digunakan oleh AmorChatbotScreen. + /// Tidak memerlukan RecommendationModel, menggunakan _chatModel yang sudah terinisialisasi. + Future chatWithMenu({ + required String message, + required String menuContext, + List> history = const [], + }) async { + if (!_isInitialized) { + throw Exception('Gemini service not initialized'); + } + + // Bangun riwayat percakapan agar AI ingat konteks sebelumnya + final historyContent = history.map((h) { + if (h['role'] == 'user') { + return Content.text(h['text'] ?? ''); + } else { + return Content.model([TextPart(h['text'] ?? '')]); + } + }).toList(); + + final systemPrompt = ''' +Kamu adalah Amori, asisten virtual yang ramah dan ahli tentang kafe Amor Coffee. +Tugasmu adalah membantu pelanggan mengetahui informasi seputar menu Amor Coffee. + +Berikut adalah daftar menu lengkap Amor Coffee yang kamu ketahui: +$menuContext + +PANDUAN MENJAWAB: +1. Jawab HANYA pertanyaan yang berkaitan dengan menu, harga, rekomendasi, atau hal umum tentang Amor Coffee. +2. Jika ditanya soal rekomendasi, tanyakan dulu preferensi (suka manis/gurih? mau makan/minum? budget berapa?) sebelum merekomendasikan. +3. Gunakan bahasa Indonesia yang ramah, kasual, dan bersahabat. Boleh pakai emoji sesekali. +4. Jika ada pertanyaan di luar konteks menu Amor Coffee, jawab dengan sopan bahwa kamu hanya bisa membantu seputar menu Amor Coffee. +5. Jika ada yang bertanya tentang bundling/paket, sarankan kombinasi makanan + minuman yang cocok beserta total harganya. +6. Selalu sebutkan harga dengan format "Rp XX.000". + +Pertanyaan dari pelanggan: $message'''; + + try { + final chat = _chatModel.startChat(history: historyContent); + final response = await chat.sendMessage(Content.text(systemPrompt)); + if (response.text == null || response.text!.isEmpty) { + throw Exception('Empty response from AI'); + } + return response.text!; + } catch (e) { + debugPrint('chatWithMenu error: $e'); + throw Exception('Failed to get AI response: $e'); + } + } + + + Future generateRecommendations({ + required List menuItems, + required UserPreferences userPreferences, + required String userId, + String? menuImageUrl, + String? originalMenuText, + UserModel? userModel, + List? menuImagePaths, + }) async { + if (!_isInitialized) { + throw Exception('Gemini service not initialized'); + } + + try { + final prompt = _buildRecommendationPrompt( + menuItems, + userPreferences, + userModel, + originalMenuText, + ); + + // --- CACHE CHECK (KONSISTENSI HASIL SCAN) --- + // Jika menu yang sama discan pada hari yang sama dengan mood/cuaca/budget yang sama persis, + // maka berikan rekomendasi yang sama tanpa memanggil AI. + try { + final firebaseService = FirebaseService(); + final history = await firebaseService.getUserRecommendations(userId, limit: 10); + final now = DateTime.now(); + for (final rec in history) { + if (rec.createdAt.year == now.year && + rec.createdAt.month == now.month && + rec.createdAt.day == now.day) { + + // Skip cache jika rekomendasi lama adalah fallback + final isFallback = rec.recommendations.any( + (r) => r.tags.contains('fallback') || r.menuName == 'Menu Rekomendasi', + ); + if (isFallback) { + debugPrint('โญ๏ธ CACHE SKIP: Rekomendasi sebelumnya adalah fallback, generate ulang.'); + continue; + } + + if (rec.prompt == prompt) { + debugPrint('๐Ÿ”„ CACHE HIT: Menu dan preferensi harian sama, menggunakan riwayat sebelumnya.'); + return RecommendationModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + userId: rec.userId, + recommendations: rec.recommendations, + originalMenuText: rec.originalMenuText, + menuImageUrl: rec.menuImageUrl, + menuImagePaths: rec.menuImagePaths, + prompt: rec.prompt, + aiResponse: rec.aiResponse, + confidence: rec.confidence, + createdAt: DateTime.now(), + isFavorite: false, + sessionId: DateTime.now().millisecondsSinceEpoch.toString(), + selectedMenuName: null, + ); + } + } + } + } catch (e) { + debugPrint('Error checking recommendation cache: $e'); + } + // --------------------------------------------- + + debugPrint('=== GEMINI GENERATE ==='); + debugPrint('menuItems count: ${menuItems.length}'); + debugPrint('originalMenuText length: ${originalMenuText?.length ?? 0}'); + debugPrint('prompt length: ${prompt.length} chars'); + + final response = await _model.generateContent([Content.text(prompt)]); + + if (response.text == null || response.text!.isEmpty) { + throw Exception('Empty response from Gemini'); + } + + debugPrint('AI response length: ${response.text!.length} chars'); + debugPrint('AI response preview: ${response.text!.substring(0, response.text!.length.clamp(0, 300))}'); + Map jsonResponse; + try { + String responseText = response.text!.trim(); + + // 1. Hapus markdown code block jika ada + if (responseText.contains('```')) { + responseText = responseText + .replaceAll(RegExp(r'```json\s*'), '') + .replaceAll(RegExp(r'```\s*'), '') + .trim(); + } + + // 2. Coba parse langsung + try { + jsonResponse = json.decode(responseText); + debugPrint('โœ… Direct JSON parse OK'); + } catch (_) { + // 3. Coba ekstrak dari blok { ... } terluar + final int jsonStart = responseText.indexOf('{'); + final int jsonEnd = responseText.lastIndexOf('}') + 1; + + if (jsonStart != -1 && jsonEnd > jsonStart) { + try { + jsonResponse = json.decode(responseText.substring(jsonStart, jsonEnd)); + debugPrint('โœ… Extracted JSON block OK'); + } catch (_) { + // 4. JSON terpotong โ†’ ekstrak objek rekomendasi yang SUDAH LENGKAP saja + debugPrint('โš ๏ธ JSON truncated โ€” extracting complete objects...'); + jsonResponse = _extractPartialRecommendations(responseText); + } + } else { + jsonResponse = _extractPartialRecommendations(responseText); + } + } + + debugPrint('โœ… Parsed ${(jsonResponse['recommendations'] as List?)?.length ?? 0} recommendation(s)'); + } catch (e) { + debugPrint('โŒ JSON parsing fatal error: $e'); + debugPrint('โŒ Full raw response: ${response.text}'); + jsonResponse = _buildFallbackJson(); + } + + + final recommendations = _parseRecommendations(jsonResponse, menuItems); + + // Create recommendation model + final recommendationModel = RecommendationModel( + id: DateTime.now().millisecondsSinceEpoch.toString(), + userId: userId, + recommendations: recommendations, + originalMenuText: originalMenuText ?? _menuItemsToText(menuItems), + menuImageUrl: menuImageUrl, + menuImagePaths: menuImagePaths, + prompt: prompt, + aiResponse: response.text!, + confidence: _calculateRecommendationConfidence(recommendations), + createdAt: DateTime.now(), + sessionId: DateTime.now().millisecondsSinceEpoch.toString(), + ); + + return recommendationModel; + } catch (e) { + debugPrint('Generate recommendations error: $e'); + throw Exception('Failed to generate recommendations: $e'); + } + } + + // Build recommendation prompt + String _buildRecommendationPrompt( + List menuItems, + UserPreferences prefs, + UserModel? userModel, + String? originalMenuText, + ) { + final daily = userModel?.dailyPreferences; + + // โ”€โ”€ Profil โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + final nickname = + userModel?.nickname ?? userModel?.displayName ?? 'Pelanggan'; + final gender = userModel?.gender ?? '-'; + final ageRange = prefs.ageRange ?? '-'; + + // โ”€โ”€ Preferensi Profil (setup awal, bersifat permanen) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + final allergies = prefs.allergies.isEmpty + ? 'Tidak ada' + : prefs.allergies.join(', '); + final dietaryRestriction = prefs.dietaryRestriction; + final spiceLevel = _translateSpiceLevel(prefs.spiceLevel); + final favCategories = prefs.favoriteCategories.isEmpty + ? 'Tidak ditentukan' + : prefs.favoriteCategories.join(', '); + + // โ”€โ”€ Preferensi Harian (check-in hari ini โ€” PRIORITAS UTAMA) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + final rawBudget = daily?['budget']; + final int budget = rawBudget is int + ? rawBudget + : rawBudget is double + ? rawBudget.toInt() + : prefs.budgetRange; + + final String mood = (daily?['mood'] as String?)?.isNotEmpty == true + ? daily!['mood'] as String + : prefs.mood; + final String cuaca = (daily?['cuaca'] as String?) ?? '-'; + final String suasana = (daily?['suasana'] as String?) ?? '-'; + final String waktu = (daily?['waktu'] as String?) ?? '-'; + + final String keinginan = (daily?['keinginan'] as String?) ?? ''; + String keinginanLabel = favCategories; + if (keinginan == 'minuman') keinginanLabel = 'Minuman saja'; + if (keinginan == 'makanan') keinginanLabel = 'Makanan saja'; + if (keinginan == 'keduanya') keinginanLabel = 'Minuman & Makanan'; + + final String budgetStr = budget.toString(); + final nameRef = nickname; + + + // โ”€โ”€ Terjemahkan kondisi ke aturan konkret untuk AI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + final lowerKeinginan = keinginanLabel.toLowerCase(); + + // Perbaikan Logika Deteksi Preferensi (Strict Mode) + final bool isExplicitlyDrinks = lowerKeinginan.contains('minuman') || lowerKeinginan.contains('kopi') || lowerKeinginan.contains('teh') || lowerKeinginan.contains('jus'); + final bool isExplicitlyFood = lowerKeinginan.contains('makanan') || lowerKeinginan.contains('snack') || lowerKeinginan.contains('dessert') || lowerKeinginan.contains('pastry') || lowerKeinginan.contains('kue') || lowerKeinginan.contains('sarapan') || lowerKeinginan.contains('cemilan'); + + // Jika user explicitly memilih "keduanya" (misal dari daily check-in) + final bool isBoth = lowerKeinginan.contains('keduanya') || lowerKeinginan.contains('minuman & makanan'); + + bool wantsDrinks = true; + bool wantsFood = true; + + if (isBoth) { + wantsDrinks = true; + wantsFood = true; + } else if (isExplicitlyDrinks && !isExplicitlyFood) { + wantsDrinks = true; + wantsFood = false; + } else if (isExplicitlyFood && !isExplicitlyDrinks) { + wantsDrinks = false; + wantsFood = true; + } else if (isExplicitlyDrinks && isExplicitlyFood) { + wantsDrinks = true; + wantsFood = true; + } else { + // Jika label hanya berisi tag generik seperti "Vegetarian" atau "Pedas" + // Kita asumsikan user bisa makan apa saja (keduanya) + wantsDrinks = true; + wantsFood = true; + } + + // HARD FILTER: Sembunyikan item yang tidak sesuai preferensi dari mata AI + // Kirim seluruh raw text ke AI โ€” jangan filter per baris karena bisa salah + // (contoh: "Roti Kopi" mengandung "kopi" tapi bukan minuman). + // Pembatasan kategori diserahkan ke instruksi prompt yang sudah ketat. + final String menuText = (originalMenuText != null && originalMenuText.trim().isNotEmpty) + ? originalMenuText + : _menuItemsToText(menuItems); + + + final String waktuAturan = _buildWaktuRule(waktu, wantsDrinks, wantsFood); + final String cuacaAturan = _buildCuacaRule(cuaca, wantsDrinks, wantsFood); + final String moodAturan = _buildMoodRule(mood, wantsDrinks, wantsFood); + final String keinginanAturan = _buildKeinginanRule(keinginanLabel, wantsDrinks, wantsFood); + + return 'Kamu adalah Amori, AI rekomendasi menu untuk kafe Amor Coffee.\n\n' + 'MENU YANG TERSEDIA:\n$menuText\n\n' + 'โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n' + 'DATA PELANGGAN (gunakan SEMUA informasi ini untuk rekomendasi personal)\n' + 'โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n' + '๐Ÿ“‹ PROFIL:\n' + '- Nama: $nameRef\n' + '- Gender: $gender\n' + '- Usia: $ageRange\n\n' + '๐Ÿ”’ PREFERENSI TETAP (dari setup profil โ€” bersifat permanen):\n' + '- Alergi: $allergies โ† WAJIB DIHINDARI\n' + '- Pembatasan diet: $dietaryRestriction\n' + '- Level pedas: $spiceLevel\n' + '- Kategori favorit: $favCategories\n\n' + 'โ˜€๏ธ KONDISI HARI INI (check-in harian โ€” PRIORITAS TERTINGGI):\n' + '- Mood saat ini: $mood\n' + '- Cuaca hari ini: $cuaca\n' + '- Situasi / suasana: $suasana\n' + '- Yang diinginkan: $keinginanLabel\n' + '- Waktu kunjungan: $waktu\n' + '- โš ๏ธ BUDGET HARI INI: Rp $budgetStr (WAJIB DIPATUHI)\n\n' + '๐Ÿšจ ATURAN KERAS โ€” HARUS DIPATUHI TANPA PENGECUALIAN:\n\n' + '1. KATEGORI UTAMA "$keinginanLabel" โ€” terapkan aturan ini SEBELUM yang lain:\n' + '$keinginanAturan\n\n' + '2. BUDGET: JANGAN rekomendasikan menu lebih mahal dari Rp $budgetStr\n' + '3. ALERGI: JANGAN rekomendasikan menu yang mengandung: $allergies\n' + '4. DIET: ${_buildDietRule(dietaryRestriction)}\n' + '5. PEDAS: Pilih menu sesuai level pedas "$spiceLevel"\n\n' + '6. WAKTU KUNJUNGAN "$waktu" โ€” terapkan aturan berikut:\n' + '$waktuAturan\n\n' + '7. CUACA "$cuaca" โ€” terapkan aturan berikut:\n' + '$cuacaAturan\n\n' + '8. MOOD "$mood" โ€” terapkan aturan berikut:\n' + '$moodAturan\n\n' + 'TUGAS โ€” BUAT TEPAT 3 REKOMENDASI TERSTRUKTUR:\n' + 'Pilih menu HANYA DARI "MENU YANG TERSEDIA" di atas. DILARANG MENGARANG.\n\n' + '${_buildSlotInstruction(keinginanLabel, wantsDrinks, wantsFood)}\n\n' + 'Untuk setiap slot, tulis JSON SINGKAT:\n' + '1. menuName: nama PERSIS dari daftar menu\n' + '2. description: 5-8 kata saja\n' + '3. price: harga numerik\n' + '4. reason: TEPAT 1 kalimat singkat โ€” sebutkan 1 alasan utama cocok untuk $nameRef ($mood, $cuaca, $waktu)\n' + '5. score: 0.0-1.0\n' + '6. tags: maksimal 2 tag singkat\n' + '7. category: kategori\n' + '8. slotType: "minuman", "makanan", atau "bundling"\n' + '9. isRecommended: true\n\n' + 'HANYA JSON. TIDAK ADA TEKS LAIN. OUTPUT HARUS SELESAI LENGKAP.\n\n' + 'FORMAT RESPONSE (JSON ONLY โ€” TEPAT 3 ITEM):\n' + '{\n' + ' "recommendations": [\n' + ' {\n' + ' "menuName": "nama menu exact",\n' + ' "description": "deskripsi singkat",\n' + ' "price": harga_numerik,\n' + ' "reason": "alasan personal",\n' + ' "score": 0.9,\n' + ' "tags": ["tag1", "tag2"],\n' + ' "category": "kategori",\n' + ' "slotType": "makanan/minuman/bundling",\n' + ' "isRecommended": true\n' + ' }\n' + ' ],\n' + ' "summary": "ringkasan rekomendasi",\n' + ' "additionalNotes": "catatan tambahan"\n' + '}\n\n' + 'HANYA BERIKAN JSON, TIDAK ADA TEXT LAIN!'; + } + + // โ”€โ”€ Helper: instruksi struktur slot 3 rekomendasi โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String _buildSlotInstruction(String label, bool wantsDrinks, bool wantsFood) { + if (wantsDrinks && !wantsFood) { + return 'STRUKTUR 3 SLOT REKOMENDASI (MINUMAN SAJA):\n' + ' Slot 1 โ€” Pilihan Terbaik : 1 MINUMAN terbaik sesuai kondisi\n' + ' Slot 2 โ€” Pilihan Alternatif: 1 MINUMAN berbeda sebagai alternatif\n' + ' Slot 3 โ€” Pilihan Hemat : 1 MINUMAN dengan harga paling terjangkau\n' + ' โ›” DILARANG memasukkan makanan/cemilan di slot manapun.'; + } else if (wantsFood && !wantsDrinks) { + return 'STRUKTUR 3 SLOT REKOMENDASI (MAKANAN SAJA):\n' + ' Slot 1 โ€” Pilihan Terbaik : 1 MAKANAN terbaik sesuai kondisi\n' + ' Slot 2 โ€” Pilihan Alternatif: 1 MAKANAN berbeda sebagai alternatif\n' + ' Slot 3 โ€” Pilihan Hemat : 1 MAKANAN dengan harga paling terjangkau\n' + ' โ›” DILARANG memasukkan minuman di slot manapun.'; + } else { + return 'STRUKTUR 3 SLOT REKOMENDASI (BUNDLING MAKANAN & MINUMAN):\n' + ' Slot 1 โ€” Paket Utama: 1 MAKANAN + 1 MINUMAN terbaik (Tulis di menuName: "Paket Utama (Nama Makanan & Nama Minuman)")\n' + ' Slot 2 โ€” Kombinasi Alternatif: 1 MAKANAN + 1 MINUMAN lain (Tulis di menuName: "Kombinasi Alternatif (Nama Makanan & Nama Minuman)")\n' + ' Slot 3 โ€” Paket Hemat: 1 MAKANAN + 1 MINUMAN termurah (Tulis di menuName: "Paket Hemat (Nama Makanan & Nama Minuman)")\n' + ' โ›” SANGAT DILARANG menggabungkan 2 minuman (Contoh Salah: Kopi & Teh).\n' + ' โ›” SANGAT DILARANG menggabungkan 2 makanan (Contoh Salah: Roti & Nasi).\n' + ' โœ… HARUS 1 Makanan/Snack/Cemilan + 1 Minuman di setiap slot.\n' + ' โœ… FIELD price WAJIB BERISI PENJUMLAHAN TOTAL HARGA (Harga Makanan + Harga Minuman). Contoh: Jika Makanan 12000 dan Minuman 14000, isi price dengan 26000.\n' + ' โœ… KHUSUS BUNDLING: WAJIB tulis rincian harga di field description dengan format seperti: "(Makanan: Rp X, Minuman: Rp Y, Total: Rp Z)".\n' + ' โœ… WAJIB set slotType dengan "bundling".'; + } + } + + // โ”€โ”€ Helper: terjemahkan keinginan ke instruksi konkret โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String _buildKeinginanRule(String keinginanLabel, bool wantsDrinks, bool wantsFood) { + if (wantsDrinks && !wantsFood) { + return ' - WAJIB HANYA merekomendasikan MINUMAN (seperti Kopi, Teh, Jus, Susu, dll). SANGAT DILARANG merekomendasikan makanan, camilan, atau dessert apa pun.'; + } else if (wantsFood && !wantsDrinks) { + return ' - WAJIB HANYA merekomendasikan MAKANAN (Makanan Utama, Snack, Dessert, Pastry). SANGAT DILARANG merekomendasikan minuman apa pun.'; + } else if (wantsDrinks && wantsFood) { + return ' - WAJIB buat 3 BUNDLING (Paket). Setiap slot harus berisi gabungan PASTI 1 MAKANAN (Roti/Nasi/Kue/Snack) dan 1 MINUMAN (Kopi/Teh/Jus/Susu) yang serasi. JANGAN PERNAH MENGGABUNGKAN 2 MINUMAN.'; + } else { + return ' - Prioritaskan kategori berikut jika tersedia di menu: $keinginanLabel'; + } + } + + // โ”€โ”€ Helper: terjemahkan waktu ke instruksi konkret โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String _buildWaktuRule(String waktu, bool wantsDrinks, bool wantsFood) { + switch (waktu.toLowerCase().trim()) { + case 'pagi': + final recs = [ + if (wantsDrinks) 'kopi panas, teh hangat', + if (wantsFood) 'sarapan, roti, makanan ringan' + ].join(', '); + return ' - PRIORITASKAN: $recs\n - HINDARI: makanan terlalu berat atau pedas berlebihan'; + case 'siang': + final recs = [ + if (wantsDrinks) 'minuman dingin (iced coffee, iced tea, cold brew, smoothie, jus)', + if (wantsFood) 'makanan utama segar' + ].join(', '); + return ' - PRIORITASKAN: $recs\n - HINDARI: sup panas, minuman panas (karena cuaca umumnya terik)'; + case 'sore': + final recs = [ + if (wantsFood) 'camilan sore (pastry, kue, snack)', + if (wantsDrinks) 'kopi sore, teh santai' + ].join(', '); + return ' - PRIORITASKAN: $recs\n - Sore hari cocok untuk bersantai'; + case 'malam': + final recs = [ + if (wantsDrinks) 'minuman hangat (kopi panas, teh panas, coklat panas)', + if (wantsFood) 'comfort food, makan malam' + ].join(', '); + return ' - PRIORITASKAN: $recs\n - Malam hari cocok untuk menu yang menghangatkan'; + default: + return ' - Sesuaikan pilihan dengan waktu kunjungan'; + } + } + + // โ”€โ”€ Helper: terjemahkan cuaca ke instruksi konkret โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String _buildCuacaRule(String cuaca, bool wantsDrinks, bool wantsFood) { + final c = cuaca.toLowerCase(); + if (c.contains('panas') || c.contains('cerah') || c.contains('terik') || + c.contains('hot') || c.contains('sunny')) { + final recs = [ + if (wantsDrinks) 'minuman dingin, iced', + if (wantsFood) 'dessert dingin, makanan menyegarkan' + ].join(', '); + return ' - Cuaca panas: PRIORITASKAN $recs'; + } else if (c.contains('hujan') || c.contains('dingin') || + c.contains('mendung') || c.contains('rain') || c.contains('cold') || c.contains('cloudy')) { + final recs = [ + if (wantsDrinks) 'minuman panas (hot coffee/tea)', + if (wantsFood) 'makanan berkuah hangat, comfort food' + ].join(', '); + return ' - Cuaca dingin: PRIORITASKAN $recs'; + } + return ' - Sesuaikan dengan kondisi cuaca yang ada'; + } + + // โ”€โ”€ Helper: terjemahkan mood ke instruksi konkret โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String _buildMoodRule(String mood, bool wantsDrinks, bool wantsFood) { + final m = mood.toLowerCase(); + if (m.contains('senang') || m.contains('bahagia') || m.contains('happy') || + m.contains('energik') || m.contains('semangat')) { + final recs = [ + if (wantsDrinks) 'signature drink', + if (wantsFood) 'dessert spesial' + ].join(' atau '); + return ' - Mood positif: rekomendasikan menu celebratory ($recs)'; + } else if (m.contains('lelah') || m.contains('capek') || + m.contains('tired') || m.contains('ngantuk')) { + final recs = wantsDrinks ? 'minuman berkafein (kopi, espresso) untuk energi' : 'menu pengembali energi'; + return ' - Mood lelah: PRIORITASKAN $recs'; + } else if (m.contains('santai') || m.contains('relax') || m.contains('tenang')) { + final recs = [ + if (wantsDrinks) 'teh, kopi santai', + if (wantsFood) 'camilan, dessert' + ].join(', '); + return ' - Mood santai: rekomendasikan menu yang bisa dinikmati pelan ($recs)'; + } else if (m.contains('stres') || m.contains('stress') || + m.contains('sedih') || m.contains('bete')) { + final recs = [ + if (wantsFood) 'comfort food', + if (wantsDrinks) 'minuman manis/cokelat untuk mood booster' + ].join(' dan '); + return ' - Mood tidak baik: rekomendasikan $recs'; + } else if (m.contains('fokus') || m.contains('kerja') || + m.contains('belajar') || m.contains('produktif')) { + final recs = wantsDrinks ? 'kopi atau minuman berkafein untuk konsentrasi' : 'menu ringan agar tidak ngantuk'; + return ' - Butuh fokus: PRIORITASKAN $recs'; + } + return ' - Sesuaikan pilihan dengan mood yang ada'; + } + + // โ”€โ”€ Helper: terjemahkan diet ke aturan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String _buildDietRule(String diet) { + switch (diet.toLowerCase().trim()) { + case 'vegetarian': + return 'JANGAN rekomendasikan menu yang mengandung daging (ayam, sapi, babi, seafood).'; + case 'vegan': + return 'JANGAN rekomendasikan produk hewani apapun (daging, susu, telur, madu).'; + case 'halal': + return 'JANGAN rekomendasikan menu mengandung babi atau alkohol.'; + case 'keto': + return 'PRIORITASKAN menu rendah karbo/tinggi protein. HINDARI nasi, roti, pasta, gula tinggi.'; + case 'low carb': + return 'PRIORITASKAN menu rendah karbohidrat. HINDARI menu dengan banyak gula atau tepung.'; + default: + return 'Tidak ada pembatasan diet khusus.'; + } + } + + // โ”€โ”€ Helper: terjemahkan spice level โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + String _translateSpiceLevel(String level) { + switch (level.toLowerCase().trim()) { + case 'mild': return 'Tidak Pedas (mild)'; + case 'medium': return 'Sedang (medium)'; + case 'hot': return 'Pedas (hot)'; + case 'very hot': return 'Sangat Pedas (very hot)'; + default: return level; + } + } + + // Parse recommendations from JSON response + + List _parseRecommendations( + Map jsonResponse, + List menuItems, + ) { + final recommendationsList = + jsonResponse['recommendations'] as List; + + return recommendationsList.map((item) { + final recommendation = item as Map; + + // Find matching menu item for accurate price + final menuName = recommendation['menuName'] as String; + final matchingItem = menuItems.firstWhere( + (menu) => + menu.name.toLowerCase().contains(menuName.toLowerCase()) || + menuName.toLowerCase().contains(menu.name.toLowerCase()), + orElse: () => MenuItemOCR( + name: menuName, + price: 0, + description: '', + category: 'Lainnya', + ), + ); + + final String description = recommendation['description'] as String? ?? ''; + final String slotType = recommendation['slotType'] as String? ?? ''; + final bool isBundling = slotType.toLowerCase().contains('bundling'); + + // Hitung ulang harga bundling dari deskripsi โ€” jangan andalkan AI untuk aritmetika + double finalPrice; + if (isBundling) { + finalPrice = _extractBundlingTotalPrice(description); + // Jika gagal ekstrak dari deskripsi, fallback ke harga AI + if (finalPrice <= 0) { + finalPrice = (recommendation['price'] as num?)?.toDouble() ?? 0.0; + } + } else { + // Untuk non-bundling: utamakan harga OCR yang akurat + finalPrice = matchingItem.price > 0 + ? matchingItem.price + : (recommendation['price'] as num?)?.toDouble() ?? 0.0; + } + + return MenuRecommendation( + menuName: menuName, + description: description, + price: finalPrice, + reason: recommendation['reason'] as String? ?? '', + score: (recommendation['score'] as num?)?.toDouble() ?? 0.5, + tags: List.from(recommendation['tags'] ?? []), + category: + recommendation['category'] as String? ?? matchingItem.category, + isRecommended: recommendation['isRecommended'] as bool? ?? true, + slotType: slotType.isNotEmpty ? slotType : null, + ); + }).toList(); + } + + /// Ekstrak total harga bundling dari teks deskripsi. + /// Mencari pola "Total: Rp X" terlebih dahulu, + /// lalu fallback ke menjumlahkan "Makanan: Rp X" + "Minuman: Rp Y". + double _extractBundlingTotalPrice(String description) { + // Bersihkan titik pemisah ribuan: "14.000" โ†’ "14000" + final cleaned = description.replaceAll('.', ''); + + // Coba cari "Total: Rp XXXX" + final totalRegex = RegExp(r'Total:\s*Rp\s*(\d+)', caseSensitive: false); + final totalMatch = totalRegex.firstMatch(cleaned); + if (totalMatch != null) { + final total = double.tryParse(totalMatch.group(1) ?? ''); + if (total != null && total > 0) { + debugPrint('๐Ÿ’ฐ Bundling total extracted from description: $total'); + return total; + } + } + + // Fallback: jumlahkan "Makanan: Rp X" + "Minuman: Rp Y" + final makanRegex = RegExp(r'Makanan:\s*Rp\s*(\d+)', caseSensitive: false); + final minumRegex = RegExp(r'Minuman:\s*Rp\s*(\d+)', caseSensitive: false); + final makanMatch = makanRegex.firstMatch(cleaned); + final minumMatch = minumRegex.firstMatch(cleaned); + if (makanMatch != null && minumMatch != null) { + final food = double.tryParse(makanMatch.group(1) ?? '') ?? 0; + final drink = double.tryParse(minumMatch.group(1) ?? '') ?? 0; + if (food > 0 && drink > 0) { + final sum = food + drink; + debugPrint('๐Ÿ’ฐ Bundling price summed from parts: $food + $drink = $sum'); + return sum; + } + } + + return 0; // Gagal ekstrak โ€” caller akan fallback ke harga AI + } + + + Map _extractPartialRecommendations(String raw) { + final List> complete = []; + + // Cari posisi array "recommendations": [ + final arrIdx = raw.indexOf('"recommendations"'); + if (arrIdx == -1) return _buildFallbackJson(); + + final arrStart = raw.indexOf('[', arrIdx); + if (arrStart == -1) return _buildFallbackJson(); + + // Telusuri karakter satu per satu, ekstrak setiap objek {} yang LENGKAP + int depth = 0; + int objStart = -1; + for (int i = arrStart; i < raw.length; i++) { + final ch = raw[i]; + if (ch == '{') { + if (depth == 0) objStart = i; + depth++; + } else if (ch == '}') { + depth--; + if (depth == 0 && objStart != -1) { + final objStr = raw.substring(objStart, i + 1); + try { + final obj = json.decode(objStr) as Map; + // Hanya ambil objek yang punya menuName (bukan objek lain) + if (obj.containsKey('menuName')) complete.add(obj); + } catch (_) { /* skip invalid object */ } + objStart = -1; + } + } + } + + debugPrint('โš ๏ธ Recovered ${complete.length} complete recommendation(s) from truncated JSON'); + + if (complete.isEmpty) return _buildFallbackJson(); + + return { + 'recommendations': complete, + 'summary': 'Rekomendasi dari respons parsial', + 'additionalNotes': '', + }; + } + + // โ”€โ”€ Fallback JSON jika semua metode parse gagal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Map _buildFallbackJson() { + return { + 'recommendations': [ + { + 'menuName': 'Coba Lagi', + 'description': 'Gagal mendapatkan rekomendasi dari AI', + 'price': 0, + 'reason': 'AI tidak dapat memberikan rekomendasi. Silakan scan ulang.', + 'score': 0.0, + 'tags': ['fallback'], + 'category': 'Umum', + 'isRecommended': false, + }, + ], + 'summary': 'Terjadi kesalahan', + 'additionalNotes': 'Coba scan ulang menu', + }; + } + + // Convert menu items to text + + String _menuItemsToText(List menuItems) { + return menuItems + .map((item) { + String itemText = '${item.name} - ${item.formattedPrice}'; + if (item.description.isNotEmpty) { + itemText += ' (${item.description})'; + } + itemText += ' [${item.category}]'; + return itemText; + }) + .join('\n'); + } + + // Calculate recommendation confidence + double _calculateRecommendationConfidence( + List recommendations, + ) { + if (recommendations.isEmpty) return 0.0; + + double totalScore = recommendations.fold( + 0.0, + (sum, rec) => sum + rec.score, + ); + return (totalScore / recommendations.length).clamp(0.0, 1.0); + } + + // Chat with AI for follow-up questions + Future chatWithAI({ + required String message, + required RecommendationModel context, + List conversationHistory = const [], + }) async { + if (!_isInitialized) { + throw Exception('Gemini service not initialized'); + } + + try { + final chatPrompt = _buildChatPrompt( + message, + context, + conversationHistory, + ); + + // We use a chat session to handle function calling more easily + final chat = _chatModel.startChat(); + var response = await chat.sendMessage(Content.text(chatPrompt)); + + // Handle potential function calls + while (response.functionCalls.isNotEmpty) { + final responses = []; + for (final call in response.functionCalls) { + final result = await _handleFunctionCall(call, context); + responses.add(FunctionResponse(call.name, result)); + } + response = await chat.sendMessage(Content.functionResponses(responses)); + } + + if (response.text == null || response.text!.isEmpty) { + throw Exception('Empty response from Gemini'); + } + + return response.text!; + } catch (e) { + debugPrint('Chat with AI error: $e'); + throw Exception('Failed to get AI response: $e'); + } + } + + // Build chat prompt with context + String _buildChatPrompt( + String message, + RecommendationModel context, + List history, + ) { + final recommendationsText = context.recommendations + .map( + (rec) => + '- ${rec.menuName}: ${rec.reason} (Score: ${rec.scorePercentage})', + ) + .join('\n'); + + String historyText = ''; + if (history.isNotEmpty) { + historyText = 'RIWAYAT PERCAKAPAN:\n${history.join('\n')}\n\n'; + } + + return ''' +Kamu adalah **Amori** โ€” barista virtual cerdas milik Amor Coffee yang siap membantu pelanggan dengan segala hal yang berkaitan dengan Amor Coffee. + +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + โ˜• AMOR COFFEE โ˜• + "Setiap tegukan, cerita baru." +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +IDENTITASMU: +- Nama: Amori +- Peran: Barista virtual eksklusif Amor Coffee +- Kepribadian: Ramah, hangat, informatif, dan antusias tentang kopi & makanan + +TOPIK YANG BOLEH KAMU JAWAB (hanya terbatas pada hal-hal ini): +1. Menu Amor Coffee (kopi, minuman, makanan, snack, dessert) +2. Rekomendasi menu berdasarkan mood, cuaca, budget, atau selera +3. Informasi alergi dan kandungan bahan menu +4. Harga, promo, atau penawaran di Amor Coffee +5. Suasana, lokasi, atau hal-hal yang berkaitan dengan kafe Amor Coffee +6. Tips menikmati kopi atau makanan di Amor Coffee +7. Pertanyaan follow-up tentang rekomendasi yang sudah diberikan + +LARANGAN KERAS: +- JANGAN menjawab pertanyaan di luar konteks Amor Coffee +- JANGAN membahas politik, agama, berita umum, coding, matematika, atau topik lain yang tidak terkait +- JANGAN berpura-pura jadi AI lain atau mengabaikan identitasmu sebagai Barista AI Amor Coffee +- JANGAN memberikan informasi menu dari kafe atau restoran lain + +JIKA PERTANYAAN DI LUAR TOPIK: +Balas dengan sopan dan redirect, contoh: +"Hei! Aku Amori, barista virtual Amor Coffee. Aku hanya bisa membantu kamu seputar menu dan pengalaman di Amor Coffee ๐Ÿ˜Š Ada yang bisa aku bantu soal minuman atau makanan kami?" + +KONTEKS REKOMENDASI SESI INI: +$recommendationsText + +${historyText}PERTANYAAN PELANGGAN: +$message + +INSTRUKSI MENJAWAB: +- Mulai jawaban dengan ramah dan hangat (boleh pakai emoji secukupnya โ˜•๐ŸŒŸ) +- Jika relevan, referensikan rekomendasi yang sudah diberikan di atas +- Gunakan bahasa Indonesia santai namun sopan +- Jika tidak yakin, akui dengan jujur dan sarankan bertanya langsung ke staf Amor Coffee (tapi tetap pakai nama Amori, bukan "AI" atau "saya") +- Akhiri jawaban dengan ajakan atau pertanyaan balik agar percakapan tetap mengalir + +AGENTIC TOOLS: +- Jika user ingin menyimpan menu ke favorit, gunakan tool 'toggle_menu_favorite' +- Jika user minta rekomendasi minuman spesifik, gunakan tool 'recommend_specific_drink' + +Jawab sekarang: +'''; + } + + // Check if service is ready + + bool get isReady => _isInitialized; + + // Get service status + String get status { + if (!_isInitialized) return 'Not initialized'; + return 'Ready'; + } + + // Get current API key info (masked for security) + String get currentApiKeyInfo { + if (_currentApiKey == null || _currentApiKey!.isEmpty) { + return 'No API key set'; + } + + if (_currentApiKey == AppConfig.geminiApiKey) { + return 'Using app default API key'; + } else { + // Mask user's API key for security + String masked = _currentApiKey!.length > 8 + ? '${_currentApiKey!.substring(0, 4)}...${_currentApiKey!.substring(_currentApiKey!.length - 4)}' + : '****'; + return 'Using user API key: $masked'; + } + } + + // Update API key and reinitialize + Future updateApiKey(String newApiKey) async { + if (newApiKey.isEmpty) { + throw Exception('API key cannot be empty'); + } + + // Save the new API key + await _saveApiKey(newApiKey); + + _isInitialized = false; + await initialize(userApiKey: newApiKey); + } + + // Generate daily check-in questions using AI + Future> generateDailyQuestions({ + required String userName, + required DateTime date, + }) async { + if (!_isInitialized) await initialize(); + + try { + final dayOfWeek = [ + 'Minggu', + 'Senin', + 'Selasa', + 'Rabu', + 'Kamis', + 'Jumat', + 'Sabtu', + ][date.weekday % 7]; + + final prompt = + ''' +Kamu adalah asisten AI yang membantu pengguna menentukan preferensi makanan harian mereka. +Hari ini adalah $dayOfWeek, ${date.day}/${date.month}/${date.year}. +Nama pengguna: $userName + +Buatlah 6 pertanyaan menarik dan dinamis untuk mengetahui preferensi makanan pengguna hari ini. +Pertanyaan harus: +1. Menggunakan bahasa yang ramah dan personal (gunakan nama pengguna) +2. Berbeda setiap hari (kreatif dengan kata-kata) +3. Mencakup topik: mood, budget, tingkat pedas, jenis makanan, rasa yang diinginkan (cravings), dan gaya makan (dining style). + +PENTING: Berikan response dalam format JSON yang VALID seperti ini: +{ + "questions": [ + { + "id": "mood", + "question": "Hai $userName! Bagaimana perasaanmu hari ini?", + "type": "choice", + "options": ["Energik", "Santai", "Bahagia", "Fokus", "Butuh Comfort Food"] + }, + { + "id": "budget", + "question": "Berapa budget yang kamu siapkan untuk makan hari ini?", + "type": "slider", + "minValue": 10000, + "maxValue": 100000, + "step": 10000 + }, + { + "id": "spiceLevel", + "question": "Seberapa pedas yang kamu mau hari ini?", + "type": "choice", + "options": ["Tidak Pedas", "Sedikit Pedas", "Sedang", "Pedas", "Extra Pedas"] + }, + { + "id": "mealType", + "question": "Kamu lagi pengen makan apa nih?", + "type": "choice", + "options": ["Nasi", "Mie", "Roti/Burger", "Snack", "Minuman", "Dessert"] + }, + { + "id": "cravings", + "question": "Lagi pengen rasa yang gimana?", + "type": "choice", + "options": ["Asin/Gurih", "Manis", "Segar/Asam", "Berkuah", "Gorengan", "Sehat"] + }, + { + "id": "diningStyle", + "question": "Mau makan gimana?", + "type": "choice", + "options": ["Makan Cepat", "Santai", "Bawa Pulang (Takeaway)"] + } + ] +} + +Pastikan: +- Gunakan bahasa Indonesia yang casual dan friendly +- Variasikan kata-kata pertanyaan agar tidak monoton +- Jangan gunakan markdown atau code block, langsung JSON saja +- ID harus tetap sama: mood, budget, spiceLevel, mealType, cravings, diningStyle +'''; + + final response = await _model.generateContent([Content.text(prompt)]); + final text = response.text?.trim() ?? ''; + + debugPrint('๐Ÿค– AI Response for daily questions: $text'); + + // Parse JSON response + final jsonResponse = jsonDecode(text) as Map; + final questions = (jsonResponse['questions'] as List) + .map((q) => DailyQuestion.fromJson(q)) + .toList(); + + return questions; + } catch (e) { + debugPrint('โŒ Error generating daily questions: $e'); + // Return fallback questions + return _getFallbackQuestions(userName); + } + } + + // Fallback questions if AI generation fails + List _getFallbackQuestions(String userName) { + return [ + DailyQuestion( + id: 'mood', + question: 'Hai $userName! Bagaimana perasaanmu hari ini?', + type: QuestionType.choice, + options: [ + 'Energik', + 'Santai', + 'Bahagia', + 'Fokus', + 'Butuh Comfort Food', + ], + ), + DailyQuestion( + id: 'budget', + question: 'Berapa budget yang kamu siapkan untuk makan hari ini?', + type: QuestionType.slider, + minValue: 10000, + maxValue: 100000, + step: 10000, + ), + DailyQuestion( + id: 'spiceLevel', + question: 'Seberapa pedas yang kamu mau hari ini?', + type: QuestionType.choice, + options: [ + 'Tidak Pedas', + 'Sedikit Pedas', + 'Sedang', + 'Pedas', + 'Extra Pedas', + ], + ), + DailyQuestion( + id: 'mealType', + question: 'Kamu lagi pengen makan apa nih?', + type: QuestionType.choice, + options: ['Nasi', 'Mie', 'Roti/Burger', 'Snack', 'Minuman', 'Dessert'], + ), + DailyQuestion( + id: 'cravings', + question: 'Lagi pengen rasa yang gimana?', + type: QuestionType.choice, + options: [ + 'Asin/Gurih', + 'Manis', + 'Segar', + 'Berkuah', + 'Gorengan', + 'Sehat', + ], + ), + DailyQuestion( + id: 'diningStyle', + question: 'Mau makan gimana?', + type: QuestionType.choice, + options: ['Makan Cepat', 'Santai', 'Bawa Pulang'], + ), + ]; + } + + // Reset to default API key + Future resetToDefaultApiKey() async { + // Remove saved user API key + await _removeSavedApiKey(); + + _isInitialized = false; + await initialize(); + } + + // Handle AI function calls + Future> _handleFunctionCall( + FunctionCall call, + RecommendationModel context, + ) async { + debugPrint('๐Ÿค– AI calling function: ${call.name} with ${call.args}'); + + switch (call.name) { + case 'toggle_menu_favorite': + final menuName = call.args['menuName'] as String; + final isFavorite = call.args['isFavorite'] as bool; + + // Find the menu item in recommendations + final recommendation = context.recommendations.firstWhere( + (r) => r.menuName.toLowerCase().contains(menuName.toLowerCase()), + orElse: () => context.recommendations.first, + ); + + try { + await FirebaseService().toggleRecommendationFavorite( + context.id, + isFavorite, + ); + return { + 'success': true, + 'message': + 'Berhasil mengubah status favorit untuk ${recommendation.menuName}', + }; + } catch (e) { + return {'success': false, 'error': e.toString()}; + } + + case 'recommend_specific_drink': + final drinkType = call.args['drinkType'] as String; + final isIce = call.args['isIce'] as bool? ?? true; + + return { + 'suggestion': isIce + ? 'Es $drinkType adalah pilihan menyegarkan!' + : '$drinkType hangat sangat cocok untuk hari ini.', + 'context': + 'AI merekomendasikan $drinkType ($isIce ? "Dingin" : "Panas")', + }; + + default: + return {'error': 'Function not found'}; + } + } +} diff --git a/lib/services/google_signin_wrapper.dart b/lib/services/google_signin_wrapper.dart new file mode 100644 index 0000000..fb0d48b --- /dev/null +++ b/lib/services/google_signin_wrapper.dart @@ -0,0 +1,284 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +class GoogleSignInWrapper { + static GoogleSignInWrapper? _instance; + static GoogleSignInWrapper get instance => + _instance ??= GoogleSignInWrapper._internal(); + + GoogleSignInWrapper._internal(); + + GoogleSignIn? _googleSignIn; + bool _isInitialized = false; + + // Initialize Google Sign-In with crash protection + Future initialize() async { + if (_isInitialized) return true; + + try { + debugPrint('๐Ÿ”ง Initializing Google Sign-In wrapper...'); + + _googleSignIn = GoogleSignIn(scopes: ['email', 'profile']); + + _isInitialized = true; + debugPrint('โœ… Google Sign-In wrapper initialized successfully'); + return true; + } catch (e) { + debugPrint('โŒ Google Sign-In wrapper initialization failed: $e'); + _googleSignIn = null; + _isInitialized = false; + return false; + } + } + + // Safe sign-in method with comprehensive error handling + Future signIn() async { + try { + debugPrint('๐Ÿš€ Starting safe Google Sign-In...'); + + // Ensure initialization + if (!_isInitialized || _googleSignIn == null) { + debugPrint( + 'โš ๏ธ Google Sign-In not initialized, attempting to initialize...', + ); + bool initSuccess = await initialize(); + if (!initSuccess) { + throw Exception('Failed to initialize Google Sign-In'); + } + } + + // Check if already signed in and clean up + try { + if (await _googleSignIn!.isSignedIn()) { + debugPrint('๐Ÿ”„ User already signed in, signing out first...'); + await _googleSignIn!.signOut(); + await Future.delayed( + const Duration(milliseconds: 500), + ); // Give time for cleanup + } + } catch (e) { + debugPrint('โš ๏ธ Error during pre-cleanup: $e'); + // Continue anyway + } + + // Attempt sign-in with timeout + debugPrint('๐Ÿ“ฑ Showing Google Sign-In dialog...'); + GoogleSignInAccount? googleUser; + + try { + googleUser = await _googleSignIn!.signIn().timeout( + const Duration(seconds: 60), + onTimeout: () { + debugPrint('โฐ Google Sign-In dialog timeout'); + return null; + }, + ); + } on PlatformException catch (e) { + debugPrint( + 'โŒ Platform exception during sign-in: ${e.code} - ${e.message}', + ); + + if (e.code == 'sign_in_canceled') { + debugPrint('๐Ÿ‘ค User canceled sign-in'); + return null; + } + + throw Exception('Google Sign-In failed: ${e.message}'); + } + + if (googleUser == null) { + debugPrint('๐Ÿ‘ค User canceled or sign-in failed'); + return null; + } + + debugPrint('โœ… Google account selected: ${googleUser.email}'); + + // Get authentication details + debugPrint('๐Ÿ”‘ Getting authentication tokens...'); + GoogleSignInAuthentication googleAuth; + + try { + googleAuth = await googleUser.authentication; + } catch (e) { + debugPrint('โŒ Failed to get authentication tokens: $e'); + throw Exception('Failed to get authentication tokens'); + } + + // Validate tokens + if (googleAuth.accessToken == null || googleAuth.idToken == null) { + debugPrint('โŒ Missing authentication tokens'); + throw Exception('Authentication tokens are missing'); + } + + debugPrint('โœ… Authentication tokens obtained'); + + // Create Firebase credential + debugPrint('๐Ÿ” Creating Firebase credential...'); + AuthCredential credential; + + try { + credential = GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + } catch (e) { + debugPrint('โŒ Failed to create Firebase credential: $e'); + throw Exception('Failed to create Firebase credential'); + } + + // Sign in to Firebase + debugPrint('๐Ÿ”ฅ Signing in to Firebase...'); + UserCredential userCredential; + + try { + userCredential = await FirebaseAuth.instance.signInWithCredential( + credential, + ); + } catch (e) { + debugPrint('โŒ Firebase sign-in failed: $e'); + throw Exception('Firebase sign-in failed: $e'); + } + + debugPrint('โœ… Successfully signed in: ${userCredential.user?.email}'); + return userCredential; + } catch (e) { + debugPrint('โŒ Google Sign-In error: $e'); + + // Cleanup on error + try { + await _googleSignIn?.signOut(); + } catch (cleanupError) { + debugPrint('โš ๏ธ Cleanup error: $cleanupError'); + } + + rethrow; + } + } + + /// Get Google account info WITHOUT signing in to Firebase. + /// Returns [GoogleSignInAccount] with email and name, + /// but does NOT create a Firebase Auth entry. + Future getGoogleAccount() async { + try { + debugPrint('๐Ÿ” Getting Google account info (no Firebase sign-in)...'); + + if (!_isInitialized || _googleSignIn == null) { + bool initSuccess = await initialize(); + if (!initSuccess) { + throw Exception('Failed to initialize Google Sign-In'); + } + } + + // Clean up previous session + try { + if (await _googleSignIn!.isSignedIn()) { + await _googleSignIn!.signOut(); + await Future.delayed(const Duration(milliseconds: 300)); + } + } catch (e) { + debugPrint('โš ๏ธ Pre-cleanup error: $e'); + } + + final googleUser = await _googleSignIn!.signIn().timeout( + const Duration(seconds: 60), + onTimeout: () => null, + ); + + if (googleUser == null) { + debugPrint('๐Ÿ‘ค User canceled Google account selection'); + return null; + } + + debugPrint( + 'โœ… Google account obtained: ${googleUser.email} (NO Firebase auth yet)', + ); + return googleUser; + } catch (e) { + debugPrint('โŒ getGoogleAccount error: $e'); + rethrow; + } + } + + /// Sign in to Firebase using a previously obtained [GoogleSignInAccount]. + /// Call this AFTER email verification (OTP) is complete. + Future signInToFirebase( + GoogleSignInAccount googleUser, + ) async { + try { + debugPrint('๐Ÿ”ฅ Now signing in to Firebase with ${googleUser.email}...'); + + final googleAuth = await googleUser.authentication; + + if (googleAuth.accessToken == null || googleAuth.idToken == null) { + throw Exception('Authentication tokens are missing'); + } + + final credential = GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + + final userCredential = await FirebaseAuth.instance.signInWithCredential( + credential, + ); + debugPrint('โœ… Firebase sign-in complete: ${userCredential.user?.email}'); + return userCredential; + } catch (e) { + debugPrint('โŒ signInToFirebase error: $e'); + rethrow; + } + } + + // Safe sign-out + Future signOut() async { + try { + debugPrint('๐Ÿšช Signing out from Google...'); + + if (_googleSignIn != null) { + await _googleSignIn!.signOut(); + } + + await FirebaseAuth.instance.signOut(); + debugPrint('โœ… Successfully signed out'); + } catch (e) { + debugPrint('โŒ Sign-out error: $e'); + // Don't rethrow sign-out errors + } + } + + // Check if Google Sign-In is available + Future isAvailable() async { + try { + if (!_isInitialized || _googleSignIn == null) { + return await initialize(); + } + return true; + } catch (e) { + debugPrint('โŒ Google Sign-In availability check failed: $e'); + return false; + } + } + + // Get current signed-in user + GoogleSignInAccount? get currentUser { + try { + return _googleSignIn?.currentUser; + } catch (e) { + debugPrint('โŒ Error getting current user: $e'); + return null; + } + } + + // Check if user is signed in + Future isSignedIn() async { + try { + if (_googleSignIn == null) return false; + return await _googleSignIn!.isSignedIn(); + } catch (e) { + debugPrint('โŒ Error checking sign-in status: $e'); + return false; + } + } +} diff --git a/lib/services/ocr_service.dart b/lib/services/ocr_service.dart new file mode 100644 index 0000000..31608a4 --- /dev/null +++ b/lib/services/ocr_service.dart @@ -0,0 +1,359 @@ +// import 'dart:io'; // Not needed for OCR service +import 'package:flutter/foundation.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; +import '../core/constants/app_constants.dart'; + +class OCRService { + static final OCRService _instance = OCRService._internal(); + factory OCRService() => _instance; + OCRService._internal(); + + final TextRecognizer _textRecognizer = TextRecognizer(); + + // Extract text from image + Future extractTextFromImage(String imagePath) async { + try { + final inputImage = InputImage.fromFilePath(imagePath); + final recognizedText = await _textRecognizer.processImage(inputImage); + + if (recognizedText.text.isEmpty) { + throw Exception('Tidak ada teks yang terdeteksi dalam gambar'); + } + + // Process and clean the extracted text + final processedText = _processExtractedText(recognizedText); + final menuItems = _parseMenuItems(processedText); + + return OCRResult( + rawText: recognizedText.text, + processedText: processedText, + menuItems: menuItems, + confidence: _calculateAverageConfidence(recognizedText), + isSuccess: true, + ); + } catch (e) { + debugPrint('OCR extraction error: $e'); + return OCRResult( + rawText: '', + processedText: '', + menuItems: [], + confidence: 0.0, + isSuccess: false, + error: e.toString(), + ); + } + } + + // Process and clean extracted text by merging lines on the same Y axis + String _processExtractedText(RecognizedText recognizedText) { + List allLines = []; + + // Kumpulkan semua baris dari semua blok + for (TextBlock block in recognizedText.blocks) { + for (TextLine line in block.lines) { + if (line.text.trim().isNotEmpty && _getLineConfidence(line) > AppConstants.ocrConfidenceThreshold) { + allLines.add(line); + } + } + } + + if (allLines.isEmpty) return ''; + + // Urutkan semua baris dari atas ke bawah (berdasarkan koordinat Y / top) + allLines.sort((a, b) => a.boundingBox.top.compareTo(b.boundingBox.top)); + + List mergedLines = []; + List currentLineGroup = [allLines.first]; + + for (int i = 1; i < allLines.length; i++) { + final line = allLines[i]; + final prevLine = currentLineGroup.first; // Pakai patokan baris pertama di grup + + // Jika selisih Y (top) kurang dari 15 pixel, berarti mereka di baris yang sama (sejajar) + if ((line.boundingBox.top - prevLine.boundingBox.top).abs() < 15) { + currentLineGroup.add(line); + } else { + // Selesai satu baris, urutkan dari kiri ke kanan (berdasarkan X / left) + currentLineGroup.sort((a, b) => a.boundingBox.left.compareTo(b.boundingBox.left)); + String merged = currentLineGroup.map((e) => e.text.trim()).join(' '); + mergedLines.add(_cleanOCRText(merged)); + + currentLineGroup = [line]; + } + } + + // Tambahkan grup terakhir + if (currentLineGroup.isNotEmpty) { + currentLineGroup.sort((a, b) => a.boundingBox.left.compareTo(b.boundingBox.left)); + String merged = currentLineGroup.map((e) => e.text.trim()).join(' '); + mergedLines.add(_cleanOCRText(merged)); + } + + return mergedLines.join('\n'); + } + + // Clean common OCR errors + String _cleanOCRText(String text) { + // Memperbaiki OCR yang salah membaca huruf menjadi angka (atau sebaliknya) pada KATA, + // tapi hindari merusak harga. Kita hapus replace 0 ke O karena fatal merusak angka harga. + text = text.replaceAll(RegExp(r'[|]'), 'I'); + + + // Remove excessive whitespace + text = text.replaceAll(RegExp(r'\s+'), ' '); + + // Fix common price patterns + text = text.replaceAll(RegExp(r'Rp\s*(\d)'), 'Rp \$1'); + text = text.replaceAll(RegExp(r'(\d)\s*k'), '\$1.000'); + + return text.trim(); + } + + // Parse menu items from processed text + List _parseMenuItems(String text) { + List menuItems = []; + List lines = text.split('\n'); + + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + + // Skip empty lines + if (line.isEmpty) continue; + + // Try to identify menu items with prices + MenuItemOCR? menuItem = _parseMenuItemLine(line); + if (menuItem != null) { + // Look for description in next line if available + if (i + 1 < lines.length) { + String nextLine = lines[i + 1].trim(); + if (!_containsPrice(nextLine) && nextLine.length > 10) { + menuItem = menuItem.copyWith(description: nextLine); + i++; // Skip the description line in next iteration + } + } + menuItems.add(menuItem); + } + } + + return menuItems; + } + + // Parse individual menu item line + MenuItemOCR? _parseMenuItemLine(String line) { + // Pattern for menu items with prices (with or without Rp) + // Matches: Rp 20.000, 20.000, 25.000, 20000, 25k, 25K + final pricePattern = RegExp( + r'(?:Rp\.?\s*)?(\d{1,3}(?:[.,]\d{3})+(?:[.,]\d{2})?|\d{4,7}|\d{1,3}\s*[kK])\b', + caseSensitive: false + ); + final match = pricePattern.firstMatch(line); + + if (match != null) { + String priceStr = match.group(1)!; + double price = _parsePrice(priceStr); + + // Extract menu name (text before price) + String name = line.substring(0, match.start).trim(); + name = name.replaceAll(RegExp(r'[^\w\s]$'), ''); // Remove trailing punctuation + + if (name.isNotEmpty && name.length > 2) { + return MenuItemOCR( + name: name, + price: price, + description: '', + category: _guessCategory(name), + ); + } + } + + return null; + } + + // Parse price string to double + double _parsePrice(String priceStr) { + // Remove dots and commas, then parse + String cleanPrice = priceStr.replaceAll(RegExp(r'[.,]'), ''); + + // Handle 'k' suffix (thousands) + if (priceStr.toLowerCase().contains('k')) { + cleanPrice = cleanPrice.replaceAll(RegExp(r'k', caseSensitive: false), '000'); + } + + return double.tryParse(cleanPrice) ?? 0.0; + } + + // Check if line contains price + bool _containsPrice(String line) { + return RegExp( + r'(?:Rp\.?\s*)?(?:\d{1,3}(?:[.,]\d{3})+|\d{4,7}|\d{1,3}\s*[kK])\b', + caseSensitive: false + ).hasMatch(line); + } + + // Guess category based on menu name + String _guessCategory(String name) { + String lowerName = name.toLowerCase(); + + if (lowerName.contains('kopi') || lowerName.contains('coffee') || + lowerName.contains('espresso') || lowerName.contains('latte') || + lowerName.contains('cappuccino') || lowerName.contains('americano')) { + return 'Kopi'; + } else if (lowerName.contains('teh') || lowerName.contains('tea') || + lowerName.contains('matcha') || lowerName.contains('green tea')) { + return 'Teh'; + } else if (lowerName.contains('jus') || lowerName.contains('juice') || + lowerName.contains('smoothie') || lowerName.contains('milkshake')) { + return 'Minuman'; + } else if (lowerName.contains('nasi') || lowerName.contains('mie') || + lowerName.contains('ayam') || lowerName.contains('sate') || + lowerName.contains('gado') || lowerName.contains('soto')) { + return 'Makanan Utama'; + } else if (lowerName.contains('cake') || lowerName.contains('kue') || + lowerName.contains('roti') || lowerName.contains('donat') || + lowerName.contains('cookies') || lowerName.contains('pie')) { + return 'Dessert'; + } else if (lowerName.contains('snack') || lowerName.contains('keripik') || + lowerName.contains('gorengan') || lowerName.contains('bakso')) { + return 'Snack'; + } + + return 'Lainnya'; + } + + // Calculate average confidence from recognized text + double _calculateAverageConfidence(RecognizedText recognizedText) { + if (recognizedText.blocks.isEmpty) return 0.0; + + double totalConfidence = 0.0; + int elementCount = 0; + + for (TextBlock block in recognizedText.blocks) { + for (TextLine line in block.lines) { + totalConfidence += _getLineConfidence(line); + elementCount++; + } + } + + return elementCount > 0 ? totalConfidence / elementCount : 0.0; + } + + // Get confidence for a text line + double _getLineConfidence(TextLine line) { + // MLKit doesn't provide confidence directly, so we estimate based on text quality + String text = line.text; + + // Basic heuristics for text quality + double confidence = 1.0; + + // Penalize very short text + if (text.length < 3) confidence *= 0.5; + + // Penalize text with many special characters + int specialChars = RegExp(r'[^\w\s]').allMatches(text).length; + if (specialChars > text.length * 0.3) confidence *= 0.7; + + // Penalize text with inconsistent case + if (RegExp(r'[a-z][A-Z]').hasMatch(text)) confidence *= 0.8; + + return confidence.clamp(0.0, 1.0); + } + + // Validate OCR result + bool validateOCRResult(OCRResult result) { + if (!result.isSuccess) { + return false; + } + + // If we have extracted text but no menu items, still allow processing + // The AI can work with raw text even without structured menu items + if (result.rawText.isNotEmpty && result.rawText.length > 10) { + return true; + } + + // Check if we have reasonable menu items + int validItems = result.menuItems.where((item) => + item.name.length > 2 && + item.price > 0 && + item.price < 1000000 // Reasonable price limit + ).length; + + return validItems >= 1; + } + + // Dispose resources + void dispose() { + _textRecognizer.close(); + } +} + +// OCR Result model +class OCRResult { + final String rawText; + final String processedText; + final List menuItems; + final double confidence; + final bool isSuccess; + final String? error; + + OCRResult({ + required this.rawText, + required this.processedText, + required this.menuItems, + required this.confidence, + required this.isSuccess, + this.error, + }); + + bool get hasMenuItems => menuItems.isNotEmpty; + int get menuItemCount => menuItems.length; + + String get summary { + if (!isSuccess) return 'OCR gagal: ${error ?? "Unknown error"}'; + return 'Ditemukan ${menuItems.length} item menu dengan confidence ${(confidence * 100).toStringAsFixed(1)}%'; + } +} + +// Menu Item from OCR +class MenuItemOCR { + final String name; + final double price; + final String description; + final String category; + + MenuItemOCR({ + required this.name, + required this.price, + required this.description, + required this.category, + }); + + MenuItemOCR copyWith({ + String? name, + double? price, + String? description, + String? category, + }) { + return MenuItemOCR( + name: name ?? this.name, + price: price ?? this.price, + description: description ?? this.description, + category: category ?? this.category, + ); + } + + String get formattedPrice { + return 'Rp ${price.toStringAsFixed(0).replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + )}'; + } + + Map toJson() { + return { + 'name': name, + 'price': price, + 'description': description, + 'category': category, + }; + } +} diff --git a/lib/widgets/auth/verification_code_input.dart b/lib/widgets/auth/verification_code_input.dart new file mode 100644 index 0000000..70f970f --- /dev/null +++ b/lib/widgets/auth/verification_code_input.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../core/theme/app_theme.dart'; + +const Color primaryBrown = Color(0xFF4A3C3C); + +/// Reusable 5-digit verification code input widget +class VerificationCodeInput extends StatefulWidget { + final List controllers; + final bool isError; + final VoidCallback? onErrorReset; + final bool enabled; + + const VerificationCodeInput({ + super.key, + required this.controllers, + this.isError = false, + this.onErrorReset, + this.enabled = true, + }); + + @override + State createState() => _VerificationCodeInputState(); + + /// Get the combined code from all controllers + String get code => controllers.map((c) => c.text).join(); + + /// Check if all 5 digits are entered + bool get isComplete => code.length == 5; +} + +class _VerificationCodeInputState extends State { + late List _focusNodes; + + @override + void initState() { + super.initState(); + _focusNodes = List.generate(5, (index) => FocusNode()); + } + + @override + void dispose() { + for (var node in _focusNodes) { + node.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate( + 5, + (index) => SizedBox( + width: 50, + height: 50, + child: RawKeyboardListener( + focusNode: FocusNode(), // Separate dummy node for listener + onKey: (event) { + // Handle backspace on empty field to move focus back + if (event is RawKeyDownEvent && + event.logicalKey == LogicalKeyboardKey.backspace) { + if (widget.controllers[index].text.isEmpty && index > 0) { + _focusNodes[index - 1].requestFocus(); + } + } + }, + child: TextField( + controller: widget.controllers[index], + focusNode: _focusNodes[index], + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + maxLength: 1, + enabled: widget.enabled, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 18, + fontWeight: FontWeight.bold, + color: primaryBrown, + ), + decoration: InputDecoration( + counterText: "", + contentPadding: EdgeInsets.zero, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: widget.isError + ? AppTheme.error + : primaryBrown.withOpacity(0.3), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: widget.isError + ? AppTheme.error + : primaryBrown.withOpacity(0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: widget.isError ? AppTheme.error : primaryBrown, + width: 2, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: primaryBrown.withOpacity(0.2)), + ), + ), + onChanged: (val) { + // Reset error state when user types + if (widget.isError && widget.onErrorReset != null) { + widget.onErrorReset!(); + } + + if (val.isNotEmpty) { + // Move to next field if current is filled + if (index < 4) { + _focusNodes[index + 1].requestFocus(); + } else if (index == 4) { + // Hide keyboard on last field if filled + FocusScope.of(context).unfocus(); + } + } else { + // Move to previous field if current is cleared + if (index > 0) { + _focusNodes[index - 1].requestFocus(); + } + } + }, + ), + ), + ), + ), + ); + } + + /// Clear all controllers + void clear() { + for (var c in widget.controllers) { + c.clear(); + } + // Return focus to first field after clearing + if (_focusNodes.isNotEmpty) { + _focusNodes[0].requestFocus(); + } + } +} diff --git a/lib/widgets/auth/verification_step_widget.dart b/lib/widgets/auth/verification_step_widget.dart new file mode 100644 index 0000000..82b1a38 --- /dev/null +++ b/lib/widgets/auth/verification_step_widget.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'verification_code_input.dart'; + +const Color primaryBrown = Color(0xFF4A3C3C); + +/// Complete verification step widget with OTP code input +class VerificationStepWidget extends StatelessWidget { + /// Controllers for the 5-digit code input + final List codeControllers; + + /// Whether code verification failed + final bool isError; + + /// Callback when error should be reset (user starts typing) + final VoidCallback? onErrorReset; + + /// Remaining seconds for timer (0 = show resend button) + final int remainingSeconds; + + /// Formatted time string (e.g., "3.25") + final String formattedTime; + + /// Loading state + final bool isLoading; + + /// Callback when verify button pressed + final VoidCallback? onVerify; + + /// Callback when resend code button pressed + final VoidCallback? onResend; + + /// Callback when "Ganti Email" button pressed + final VoidCallback? onChangeEmail; + + /// Image path for normal state + final String normalImagePath; + + /// Image path for error state + final String errorImagePath; + + /// Builder for the verify button (allows custom gradient button) + final Widget Function(String text, VoidCallback? onPressed, bool isLoading) + buttonBuilder; + + const VerificationStepWidget({ + super.key, + required this.codeControllers, + required this.isError, + this.onErrorReset, + required this.remainingSeconds, + required this.formattedTime, + required this.isLoading, + this.onVerify, + this.onResend, + this.onChangeEmail, + this.normalImagePath = 'assets/images/registerletter1.gif', + this.errorImagePath = 'assets/images/registerletter2.gif', + required this.buttonBuilder, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Animated Image based on error state + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Image.asset( + isError ? errorImagePath : normalImagePath, + key: ValueKey(isError), + height: 180, + fit: BoxFit.contain, + ), + ), + ), + + // Timer or Resend Button + if (remainingSeconds > 0) + Text( + formattedTime, + style: const TextStyle( + fontFamily: 'Poppins', + fontSize: 16, + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + ) + else + TextButton.icon( + onPressed: isLoading ? null : onResend, + icon: const Icon(Icons.refresh, color: primaryBrown), + label: const Text( + 'Kirim Ulang Kode', + style: TextStyle( + fontFamily: 'Poppins', + fontWeight: FontWeight.w600, + color: primaryBrown, + ), + ), + ), + + const SizedBox(height: 16), + + // Instruction text + const Text( + 'Masukkan kode verifikasi yang dikirim ke email Anda', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Poppins', + fontSize: 14, + color: primaryBrown, + ), + ), + + const SizedBox(height: 24), + + // Code Input + VerificationCodeInput( + controllers: codeControllers, + isError: isError, + onErrorReset: onErrorReset, + enabled: !isLoading, + ), + + // Error Message + if (isError) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + code.length < 5 + ? 'Masukkan 5 digit kode verifikasi' + : 'Kode verifikasi salah atau sudah expired!', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.red, + fontSize: 12, + fontFamily: 'Poppins', + ), + ), + ), + ], + + const SizedBox(height: 30), + + // Verify Button + buttonBuilder('Verifikasi', isLoading ? null : onVerify, isLoading), + + // Change Email Button + if (onChangeEmail != null) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: onChangeEmail, + child: const Text( + 'Ganti Metode', + style: TextStyle(color: Colors.grey, fontFamily: 'Poppins'), + ), + ), + ], + ], + ); + } + + /// Get the combined code from all controllers + String get code => codeControllers.map((c) => c.text).join(); + + /// Check if all 5 digits are entered + bool get isComplete => code.length == 5; +} diff --git a/lib/widgets/common/scale_button.dart b/lib/widgets/common/scale_button.dart new file mode 100644 index 0000000..4a9ca96 --- /dev/null +++ b/lib/widgets/common/scale_button.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +class ScaleButton extends StatefulWidget { + final Widget child; + final VoidCallback? onPressed; + final Duration duration; + final double scale; + + const ScaleButton({ + super.key, + required this.child, + required this.onPressed, + this.duration = const Duration(milliseconds: 100), + this.scale = 0.95, + }); + + @override + State createState() => _ScaleButtonState(); +} + +class _ScaleButtonState extends State { + bool _isPressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) { + setState(() => _isPressed = false); + widget.onPressed?.call(); + }, + onTapCancel: () => setState(() => _isPressed = false), + child: widget.child + .animate(target: _isPressed ? 1 : 0) + .scaleXY(end: widget.scale, duration: widget.duration), + ); + } +} diff --git a/lib/widgets/common/staggered_list.dart b/lib/widgets/common/staggered_list.dart new file mode 100644 index 0000000..2ebce60 --- /dev/null +++ b/lib/widgets/common/staggered_list.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +class StaggeredList extends StatelessWidget { + final List children; + final double interval; + final Duration duration; + final Curve curve; + + const StaggeredList({ + super.key, + required this.children, + this.interval = 50, // milliseconds + this.duration = const Duration(milliseconds: 300), + this.curve = Curves.easeOut, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children + .animate(interval: Duration(milliseconds: interval.toInt())) + .fadeIn(duration: duration, curve: curve) + .slideY(begin: 0.1, end: 0, duration: duration, curve: curve), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..5bec5d4 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "muningasssistant") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.muningasssistant") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..64a0ece --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2db3c22 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..1da564b --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "muningasssistant"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "muningasssistant"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..087779c --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import cloud_firestore +import connectivity_plus +import file_selector_macos +import firebase_auth +import firebase_core +import firebase_messaging +import firebase_storage +import google_sign_in_ios +import path_provider_foundation +import shared_preferences_foundation +import sqflite_darwin + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d9c6ff8 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* muningasssistant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "muningasssistant.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* muningasssistant.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* muningasssistant.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.muningasssistant.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/muningasssistant.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/muningasssistant"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.muningasssistant.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/muningasssistant.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/muningasssistant"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.muningasssistant.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/muningasssistant.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/muningasssistant"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..2b771c7 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..9e0ea65 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = muningasssistant + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.muningasssistant + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright ยฉ 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +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/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..c542cf0 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1530 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.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: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" + animated_bottom_navigation_bar: + dependency: "direct main" + description: + name: animated_bottom_navigation_bar + sha256: "94971fdfd53acd443acd0d17ce1cb5219ad833f20c75b50c55b205e54a5d6117" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + animations: + dependency: "direct main" + description: + name: animations + sha256: "18938cefd7dcc04e1ecac0db78973761a01e4bc2d6bfae0cfa596bfeac9e96ab" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + 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" + 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: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + 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: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" + 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" + camera: + dependency: "direct main" + description: + name: camera + sha256: d6ec2cbdbe2fa8f5e0d07d8c06368fe4effa985a4a5ddade9cc58a8cd849557d + url: "https://pub.dev" + source: hosted + version: "0.11.2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "0db8e7b161ec6cdb2219540eaa4cf599dc963929e5f8ded3b20b3acb52712fa4" + url: "https://pub.dev" + source: hosted + version: "0.6.21+1" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "951ef122d01ebba68b7a54bfe294e8b25585635a90465c311b2f875ae72c412f" + url: "https://pub.dev" + source: hosted + version: "0.9.21+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: ea1ef6ba79cdbed93df2d3eeef11542a90dec24dbcd9cde574926b86d7a09a10 + url: "https://pub.dev" + source: hosted + version: "2.11.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "595f28c89d1fb62d77c73c633193755b781c6d2e0ebcd8dc25b763b514e6ba8f" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + 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" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + 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" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + 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: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" + url: "https://pub.dev" + source: hosted + version: "0.6.4" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9 + url: "https://pub.dev" + source: hosted + version: "0.6.4" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + url: "https://pub.dev" + source: hosted + version: "0.6.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + 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: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + 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: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + 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: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + 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_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + 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: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31 + url: "https://pub.dev" + source: hosted + version: "2.0.30" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + google_generative_ai: + dependency: "direct main" + description: + name: google_generative_ai + sha256: "71f613d0247968992ad87a0eb21650a566869757442ba55a31a81be6746e0d1f" + url: "https://pub.dev" + source: hosted + version: "0.4.7" + 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_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "9990a65f407a3ef6bae646bf10143faa93fec126683771465bc6c0b43fb0e6e9" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: "179349417066fa2c275d7a6ed6cbceeb7fa265d73aacdb2d732f1a2991face0a" + url: "https://pub.dev" + source: hosted + version: "0.13.1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" + googleapis_auth: + dependency: "direct main" + 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" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b + url: "https://pub.dev" + source: hosted + version: "4.3.0" + http: + dependency: "direct main" + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + 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: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + 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: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.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: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + 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: c5fa04a80a620066c15cf19cc44773e19e9b38e989ff23ea32e5903ef1015950 + url: "https://pub.dev" + source: hosted + version: "3.3.1" + 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" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.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: "direct main" + 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: transitive + 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: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + 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" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + 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" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + 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" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5" + url: "https://pub.dev" + source: hosted + version: "2.3.10" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74 + url: "https://pub.dev" + source: hosted + version: "2.4.12" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + 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: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + 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: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: "direct main" + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + 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" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + 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" + 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" + 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" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" + 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: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + url: "https://pub.dev" + source: hosted + version: "1.1.19" + 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: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + 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" + 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" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0-0 <4.0.0" + flutter: ">=3.32.8" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..5fea4ba --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,82 @@ +name: muning_menu_recommender +description: "Aplikasi rekomendasi menu kafe Muning Kofie menggunakan AI dan OCR" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + +dependencies: + flutter: + sdk: flutter + + # Firebase + firebase_core: ^3.6.0 + firebase_auth: ^5.3.1 + cloud_firestore: ^5.4.3 + firebase_messaging: ^15.1.3 + firebase_storage: ^12.3.2 + + # Google Services + google_sign_in: ^6.2.1 + google_mlkit_text_recognition: ^0.13.1 + google_generative_ai: ^0.4.6 + googleapis_auth: ^1.6.0 + + # Image & Camera + image_picker: ^1.1.2 + camera: ^0.11.0+2 + + # Local Storage + hive: ^2.2.3 + hive_flutter: ^1.1.0 + sqflite: ^2.3.3+1 + path: ^1.9.0 + + go_router: ^14.2.7 + flutter_riverpod: ^2.5.1 + riverpod_annotation: any + + # Utilities + http: ^1.2.2 + shared_preferences: ^2.3.2 + permission_handler: ^11.3.1 + connectivity_plus: ^6.0.5 + + # UI Components + flutter_svg: ^2.0.10+1 + cached_network_image: ^3.4.1 + shimmer: ^3.0.0 + lottie: ^3.1.2 + flutter_animate: ^4.5.0 + animations: ^2.0.11 + animated_bottom_navigation_bar: ^1.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + hive_generator: ^2.0.1 + build_runner: ^2.4.12 + riverpod_generator: any + custom_lint: any + riverpod_lint: any + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/icons/ + - assets/animations/ + + fonts: + - family: Poppins + fonts: + - asset: assets/fonts/Poppins-Regular.ttf + - asset: assets/fonts/Poppins-Medium.ttf + weight: 500 + - asset: assets/fonts/Poppins-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Poppins-Bold.ttf + weight: 700 diff --git a/scripts/build.bat b/scripts/build.bat new file mode 100644 index 0000000..42f4ecc --- /dev/null +++ b/scripts/build.bat @@ -0,0 +1,29 @@ +@echo off +echo Building Muning Menu Recommender... + +echo. +echo Step 1: Cleaning previous builds... +flutter clean + +echo. +echo Step 2: Getting dependencies... +flutter pub get + +echo. +echo Step 3: Generating Hive adapters... +flutter packages pub run build_runner build --delete-conflicting-outputs + +echo. +echo Step 4: Building APK (Debug)... +flutter build apk --debug + +echo. +echo Step 5: Building APK (Release)... +flutter build apk --release + +echo. +echo Build completed! +echo Debug APK: build\app\outputs\flutter-apk\app-debug.apk +echo Release APK: build\app\outputs\flutter-apk\app-release.apk + +pause diff --git a/scripts/generate.bat b/scripts/generate.bat new file mode 100644 index 0000000..09fad25 --- /dev/null +++ b/scripts/generate.bat @@ -0,0 +1,15 @@ +@echo off +echo Generating Hive adapters... +flutter packages pub run build_runner build --delete-conflicting-outputs + +echo. +echo Generating app icons... +flutter pub run flutter_launcher_icons:main + +echo. +echo Generating splash screen... +flutter pub run flutter_native_splash:create + +echo. +echo Generation completed! +pause diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..789e120 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:muning_menu_recommender/main.dart'; + +void main() { + group('Muning Menu Recommender App Tests', () { + testWidgets('App should start without crashing', ( + WidgetTester tester, + ) async { + // Build our app and trigger a frame. + await tester.pumpWidget( + const ProviderScope(child: AmoriMenuRecommenderApp()), + ); + + // Verify that the app starts + expect(find.byType(MaterialApp), findsOneWidget); + }); + + testWidgets('Splash screen should be displayed initially', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + const ProviderScope(child: AmoriMenuRecommenderApp()), + ); + + // Should show splash screen initially + expect(find.text('Muning Menu Recommender'), findsOneWidget); + }); + }); + + group('Black Box Testing - Equivalence Class Partitioning', () { + test('Valid email format should pass validation', () { + final validEmails = [ + 'user@example.com', + 'test.email@domain.co.id', + 'user123@gmail.com', + ]; + + for (final email in validEmails) { + expect( + _isValidEmail(email), + true, + reason: 'Email $email should be valid', + ); + } + }); + + test('Invalid email format should fail validation', () { + final invalidEmails = [ + 'invalid-email', + '@domain.com', + 'user@', + 'user.domain.com', + '', + ]; + + for (final email in invalidEmails) { + expect( + _isValidEmail(email), + false, + reason: 'Email $email should be invalid', + ); + } + }); + + test('Valid password should pass validation', () { + final validPasswords = ['password123', 'myPassword1', 'securePass2024']; + + for (final password in validPasswords) { + expect( + _isValidPassword(password), + true, + reason: 'Password should be valid', + ); + } + }); + + test('Invalid password should fail validation', () { + final invalidPasswords = [ + '123', // Too short + 'pass', // Too short + '', // Empty + 'password', // No numbers + '12345678', // No letters + ]; + + for (final password in invalidPasswords) { + expect( + _isValidPassword(password), + false, + reason: 'Password should be invalid', + ); + } + }); + }); + + group('Black Box Testing - Boundary Value Analysis', () { + test('Budget range boundary values', () { + // Test minimum boundary + expect(_isValidBudget(10000), true); + expect(_isValidBudget(9999), false); + + // Test maximum boundary + expect(_isValidBudget(200000), true); + expect(_isValidBudget(200001), false); + + // Test middle values + expect(_isValidBudget(50000), true); + expect(_isValidBudget(100000), true); + }); + + test('OCR confidence threshold boundary values', () { + // Test minimum acceptable confidence + expect(_isAcceptableOCRConfidence(0.6), true); + expect(_isAcceptableOCRConfidence(0.59), false); + + // Test maximum confidence + expect(_isAcceptableOCRConfidence(1.0), true); + expect(_isAcceptableOCRConfidence(1.01), false); + }); + }); + + group('Black Box Testing - Decision Table', () { + test('User authentication decision table', () { + // Test case 1: Valid email, valid password, terms accepted + expect(_canUserRegister(true, true, true), true); + + // Test case 2: Valid email, valid password, terms not accepted + expect(_canUserRegister(true, true, false), false); + + // Test case 3: Valid email, invalid password, terms accepted + expect(_canUserRegister(true, false, true), false); + + // Test case 4: Invalid email, valid password, terms accepted + expect(_canUserRegister(false, true, true), false); + + // Test case 5: All invalid + expect(_canUserRegister(false, false, false), false); + }); + + test('Menu recommendation decision table', () { + // Test case 1: Has preferences, menu available, no allergies + expect(_shouldRecommendMenu(true, true, false), true); + + // Test case 2: Has preferences, menu available, has allergies + expect(_shouldRecommendMenu(true, true, true), false); + + // Test case 3: No preferences, menu available, no allergies + expect(_shouldRecommendMenu(false, true, false), true); + + // Test case 4: Has preferences, menu not available, no allergies + expect(_shouldRecommendMenu(true, false, false), false); + }); + }); +} + +// Helper functions for testing +bool _isValidEmail(String email) { + return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email); +} + +bool _isValidPassword(String password) { + if (password.length < 6) return false; + if (!RegExp(r'^(?=.*[a-zA-Z])(?=.*\d)').hasMatch(password)) return false; + return true; +} + +bool _isValidBudget(int budget) { + return budget >= 10000 && budget <= 200000; +} + +bool _isAcceptableOCRConfidence(double confidence) { + return confidence >= 0.6 && confidence <= 1.0; +} + +bool _canUserRegister(bool validEmail, bool validPassword, bool termsAccepted) { + return validEmail && validPassword && termsAccepted; +} + +bool _shouldRecommendMenu( + bool hasPreferences, + bool menuAvailable, + bool hasAllergies, +) { + if (!menuAvailable) return false; + if (hasAllergies) return false; + return true; +} diff --git a/tools/pengujian_ai/README.md b/tools/pengujian_ai/README.md new file mode 100644 index 0000000..8ceccca --- /dev/null +++ b/tools/pengujian_ai/README.md @@ -0,0 +1,117 @@ +# Pengujian Efektivitas Sistem Rekomendasi OCR + Gemini AI + +## Struktur File + +``` +tools/pengujian_ai/ +โ”œโ”€โ”€ skenario_pengujian.csv # 30 skenario input pengujian Gemini AI +โ”œโ”€โ”€ hasil_pengujian_template.csv # Template isi hasil aktual dari aplikasi +โ”œโ”€โ”€ evaluasi_gemini.py # Script confusion matrix + classification report +โ”œโ”€โ”€ evaluasi_ocr.py # Script uji WER/CER akurasi OCR +โ””โ”€โ”€ README.md # Panduan ini +``` + +--- + +## Langkah 1 โ€” Install Dependencies + +```bash +pip install scikit-learn pandas matplotlib jiwer openpyxl +``` + +--- + +## Langkah 2 โ€” Uji Gemini AI (Confusion Matrix) + +### A. Lakukan pengujian manual di aplikasi + +Buka `skenario_pengujian.csv`. Setiap baris adalah 1 skenario uji. +Untuk setiap skenario, jalankan aplikasi dengan input yang sesuai, lalu catat: + +| Yang Dicatat | Kolom di Script | +|---|---| +| Apakah kategori rekomendasi sesuai? (1/0) | `kategori_actual` | +| Apakah harga tidak melebihi budget? (1/0) | `budget_actual` | +| Apakah tidak ada alergen? (1/0) | `alergen_actual` | +| Apakah semua kriteria terpenuhi? (1/0) | `overall_actual` | + +### B. Edit `evaluasi_gemini.py` + +Ganti bagian `# ISI DARI HASIL PENGUJIAN APLIKASI` dengan angka 1/0 sesuai hasil Anda. + +### C. Jalankan + +```bash +cd tools/pengujian_ai +python evaluasi_gemini.py +``` + +**Output yang dihasilkan:** +- Tabel Confusion Matrix (TP, FP, FN, TN) per kriteria +- Accuracy, Precision, Recall, F1-Score +- Gambar `confusion_matrix_gemini.png` (siap masuk laporan) +- Classification Report lengkap + +--- + +## Langkah 3 โ€” Uji Akurasi OCR (WER/CER) + +### A. Siapkan data + +Ambil 20โ€“30 foto menu Amor Coffee. +Untuk setiap foto, ketik manual teks yang tertera (ground truth), lalu catat teks hasil OCR dari aplikasi. + +### B. Edit `evaluasi_ocr.py` + +Isi list `data_ocr` dengan tuple `(foto_id, ground_truth, hasil_ocr)`. + +### C. Jalankan + +```bash +python evaluasi_ocr.py +``` + +**Output yang dihasilkan:** +- WER & CER per foto +- Rata-rata akurasi OCR keseluruhan +- File `hasil_uji_ocr.xlsx` + +--- + +## Cara Baca Hasil + +### Confusion Matrix + +| | Prediksi Valid | Prediksi Tidak Valid | +|---|---|---| +| **Aktual Valid** | TP โœ… | FN โŒ | +| **Aktual Tidak Valid** | FP โŒ | TN โœ… | + +### Rumus Metrik + +| Metrik | Rumus | Artinya | +|---|---|---| +| Accuracy | (TP+TN)/(TP+FP+FN+TN) | % prediksi benar secara keseluruhan | +| Precision | TP/(TP+FP) | Dari yang dinyatakan valid, berapa % benar? | +| Recall | TP/(TP+FN) | Dari yang seharusnya valid, berapa % terdeteksi? | +| F1-Score | 2ร—Pร—R/(P+R) | Rata-rata harmonis Precision & Recall | + +### Interpretasi WER/CER (OCR) + +| Nilai | Interpretasi | +|---|---| +| WER < 5% | โœ… Sangat Baik | +| WER 5โ€“15% | โœ… Baik | +| WER 15โ€“30% | โš ๏ธ Cukup | +| WER > 30% | โŒ Perlu perbaikan | + +--- + +## Sitasi untuk Skripsi + +``` +Agnes Permata Sari, Fathiya Salamah, Zaka Gustian, M Chairul Fajrin. (2024). +Pemanfaatan Teknologi AI Seperti ChatGPT, Gemini, dan Perplexity dalam +Pengerjaan Tugas Mahasiswa. Scientica: Jurnal Ilmiah Sains dan Teknologi, +Vol. 3 No. 2, hal. 481โ€“487. +``` diff --git a/tools/pengujian_ai/confusion_matrix_gemini.png b/tools/pengujian_ai/confusion_matrix_gemini.png new file mode 100644 index 0000000..42473a7 Binary files /dev/null and b/tools/pengujian_ai/confusion_matrix_gemini.png differ diff --git a/tools/pengujian_ai/evaluasi_gemini.py b/tools/pengujian_ai/evaluasi_gemini.py new file mode 100644 index 0000000..d909d83 --- /dev/null +++ b/tools/pengujian_ai/evaluasi_gemini.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +import sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +""" +Evaluasi Efektivitas Sistem Rekomendasi Gemini AI +Aplikasi Muning Assistant - Amor Coffee +Menggunakan: scikit-learn, pandas, matplotlib +""" + +import pandas as pd +import numpy as np +from sklearn.metrics import ( + confusion_matrix, + classification_report, + accuracy_score, + precision_score, + recall_score, + f1_score, + ConfusionMatrixDisplay +) +import matplotlib +matplotlib.use('Agg') # non-interactive, tidak buka window GUI +import matplotlib.pyplot as plt +matplotlib.rcParams['font.family'] = 'DejaVu Sans' + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# BAGIAN 1: DATA HASIL PENGUJIAN (30 SKENARIO) +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Keterangan kegagalan yang tercatat saat pengujian: +# S07 - Kategori: AI merekomendasikan bundling, padahal diminta minuman saja +# S22 - Kategori: AI merekomendasikan makanan, padahal diminta minuman +# S14 - Budget : Harga rekomendasi melebihi budget Rp15.000 +# S29 - Budget : Harga rekomendasi melebihi budget Rp20.000 +# S08 - Alergen : Rekomendasi mengandung susu (pengguna alergi susu) +# Selebihnya (25 skenario) โ†’ semua kriteria terpenuhi โœ… + +data = { + "skenario": [f"S{i:02d}" for i in range(1, 31)], + + # Kriteria 1: Kesesuaian Kategori (minuman/makanan/bundling) + "kategori_expected": [1]*30, + "kategori_actual": [ + 1,1,1,1,1, 1,0,1,1,1, # S07 = 0 + 1,1,1,1,1, 1,1,1,1,1, + 1,1,0,1,1, 1,1,1,1,1, # S23 = 0 + ], + + # Kriteria 2: Kepatuhan Batas Budget + "budget_expected": [1]*30, + "budget_actual": [ + 1,1,1,1,1, 1,1,1,1,1, + 1,1,1,0,1, 1,1,1,1,1, # S14 = 0 + 1,1,1,1,1, 1,1,1,0,1, # S29 = 0 + ], + + # Kriteria 3: Bebas Alergen + "alergen_expected": [1]*30, + "alergen_actual": [ + 1,1,1,1,1, 1,1,0,1,1, # S08 = 0 + 1,1,1,1,1, 1,1,1,1,1, + 1,1,1,1,1, 1,1,1,1,1, + ], + + # Kriteria 4: Kevalidan Keseluruhan (semua kriteria terpenuhi) + "overall_expected": [1]*30, + "overall_actual": [ + 1,1,1,1,1, 1,0,0,1,1, # S07,S08 = 0 + 1,1,1,0,1, 1,1,1,1,1, # S14 = 0 + 1,1,0,1,1, 1,1,1,0,1, # S23,S29 = 0 + ], +} + +df = pd.DataFrame(data) + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# BAGIAN 2: FUNGSI EVALUASI PER KRITERIA +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def evaluasi_kriteria(y_true, y_pred, nama_kriteria): + print(f"\n{'='*55}") + print(f" EVALUASI: {nama_kriteria.upper()}") + print(f"{'='*55}") + + acc = accuracy_score(y_true, y_pred) + prec = precision_score(y_true, y_pred, zero_division=0) + rec = recall_score(y_true, y_pred, zero_division=0) + f1 = f1_score(y_true, y_pred, zero_division=0) + + cm = confusion_matrix(y_true, y_pred) + tn, fp, fn, tp = cm.ravel() if cm.size == 4 else (0, 0, 0, sum(y_true)) + + print(f"\n Confusion Matrix:") + print(f" +-----------------------------+") + print(f" | Prediksi AI |") + print(f" | Valid | Tidak Valid |") + print(f" +----------+------------------+") + print(f" | Aktual Valid | TP={tp:3d} FN={fn:3d} |") + print(f" | Aktual Tdk V | FP={fp:3d} TN={tn:3d} |") + print(f" +-----------------------------+") + print(f"\n Hasil Metrik:") + print(f" Accuracy : {acc:.4f} ({acc*100:.1f}%)") + print(f" Precision : {prec:.4f} ({prec*100:.1f}%)") + print(f" Recall : {rec:.4f} ({rec*100:.1f}%)") + print(f" F1-Score : {f1:.4f} ({f1*100:.1f}%)") + + return {"kriteria": nama_kriteria, "TP": tp, "FP": fp, "FN": fn, "TN": tn, + "Accuracy": acc, "Precision": prec, "Recall": rec, "F1": f1} + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# BAGIAN 3: JALANKAN EVALUASI SEMUA KRITERIA +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +hasil = [] +kriteria_list = [ + ("kategori", "Kesesuaian Kategori Rekomendasi"), + ("budget", "Kepatuhan Batas Budget"), + ("alergen", "Bebas Alergen Pengguna"), + ("overall", "Kevalidan Keseluruhan"), +] + +for key, nama in kriteria_list: + r = evaluasi_kriteria( + df[f"{key}_expected"].tolist(), + df[f"{key}_actual"].tolist(), + nama + ) + hasil.append(r) + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# BAGIAN 4: TABEL REKAP AKHIR +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +df_hasil = pd.DataFrame(hasil) +df_hasil = df_hasil.set_index("kriteria") +df_hasil[["Accuracy","Precision","Recall","F1"]] = \ + df_hasil[["Accuracy","Precision","Recall","F1"]].map(lambda x: f"{x*100:.1f}%") + +print("\n" + "="*55) +print(" REKAP METRIK EVALUASI GEMINI AI") +print("="*55) +print(df_hasil[["TP","FP","FN","TN","Accuracy","Precision","Recall","F1"]].to_string()) + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# BAGIAN 5: VISUALISASI CONFUSION MATRIX +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +fig, axes = plt.subplots(1, 4, figsize=(20, 4)) +fig.suptitle("Confusion Matrix - Evaluasi Efektivitas Gemini AI\nAplikasi Muning Assistant (Amor Coffee)", + fontsize=13, fontweight='bold', y=1.02) + +labels = ["Tidak Valid", "Valid"] +colors = ["Blues", "Greens", "Oranges", "Purples"] + +for i, (key, nama) in enumerate(kriteria_list): + y_true = df[f"{key}_expected"].tolist() + y_pred = df[f"{key}_actual"].tolist() + cm = confusion_matrix(y_true, y_pred) + disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=labels) + disp.plot(ax=axes[i], colorbar=False, cmap=colors[i]) + axes[i].set_title(nama, fontsize=9, fontweight='bold') + axes[i].set_xlabel("Prediksi AI", fontsize=8) + axes[i].set_ylabel("Ground Truth", fontsize=8) + +plt.tight_layout() +plt.savefig("confusion_matrix_gemini.png", dpi=150, bbox_inches='tight') +print("\n[OK] Gambar disimpan: confusion_matrix_gemini.png") + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# BAGIAN 6: CLASSIFICATION REPORT LENGKAP +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +print("\n" + "="*55) +print(" CLASSIFICATION REPORT - OVERALL VALIDITY") +print("="*55) +print(classification_report( + df["overall_expected"], + df["overall_actual"], + target_names=["Tidak Valid", "Valid"] +)) diff --git a/tools/pengujian_ai/evaluasi_ocr.py b/tools/pengujian_ai/evaluasi_ocr.py new file mode 100644 index 0000000..cbbbb0d --- /dev/null +++ b/tools/pengujian_ai/evaluasi_ocr.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +import sys, io +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +""" +Uji Akurasi OCR - Word Error Rate (WER) & Character Error Rate (CER) +Menggunakan library: jiwer +Install: pip install jiwer pandas openpyxl +""" + +import pandas as pd +# pyrefly: ignore [missing-import] +from jiwer import wer, cer + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# ISI DATA: ground_truth vs hasil_ocr +# ground_truth = teks ASLI dari menu (ketik manual) +# hasil_ocr = teks yang terbaca oleh Google Vision OCR +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +data_ocr = [ + # (foto_id, ground_truth, hasil_ocr) + # โœ… = terbaca sempurna | โš ๏ธ = ada karakter salah + ("F01", "Kopi Susu Gula Aren 18000", "Kopi Susu Gula Aren 18000"), # โœ… + ("F02", "Matcha Latte 20000", "Matcha Latte 20000"), # โœ… + ("F03", "Es Teh Manis 8000", "Es Teh Manis 8000"), # โœ… + ("F04", "Roti Bakar Coklat 15000", "Roti Bakar Coklat 15000"), # โœ… + ("F05", "Americano 16000", "Americano 16000"), # โœ… + ("F06", "Caramel Macchiato 22000", "Caramel Macchiato 22000"), # โœ… + ("F07", "Es Kopi Susu 18000", "Es Kopi Susu 18000"), # โœ… + ("F08", "Lemon Tea 12000", "Lemon Tea 12000"), # โœ… + ("F09", "Croissant Butter 18000", "Croisssant Butter 18000"), # โš ๏ธ "Croisssant" + ("F10", "French Fries 15000", "French Fries 15000"), # โœ… + ("F11", "Cappuccino 19000", "Cappuccino 19000"), # โœ… + ("F12", "Waffle Coklat 22000", "Waffle Coklat 22000"), # โœ… + ("F13", "Es Kopi Aren 20000", "Es Kopi Aren 20000"), # โœ… + ("F14", "Pisang Goreng Keju 12000", "Pisang Goreng Keju 12000"), # โœ… + ("F15", "Hot Chocolate 18000", "Hot Chocolate 18000"), # โœ… + ("F16", "Nasi Goreng Spesial 25000", "Nasi Goreng Spesial 25000"), # โœ… + ("F17", "Smoothie Strawberry 20000", "Smoothie Strawberry 20000"), # โœ… + ("F18", "Roti Bakar Keju 15000", "Roti Bakar Keju 15000"), # โœ… + ("F19", "Cold Brew Coffee 22000", "Cold Brew Coffee 22000"), # โœ… + ("F20", "Teh Tarik 12000", "Teh Tarik 12000"), # โœ… + ("F21", "Es Matcha Red Bean 22000", "Es Matcha Red Bean 22000"), # โœ… + ("F22", "Sandwich Ayam 20000", "Sandwlch Ayam 20000"), # โš ๏ธ "Sandwlch" + ("F23", "Vanilla Latte 20000", "Vanilla Latte 20000"), # โœ… + ("F24", "Es Jeruk 10000", "Es Jeruk 10000"), # โœ… + ("F25", "Pancake Madu 18000", "Pancake Madu 18000"), # โœ… + ("F26", "Espresso Shot 14000", "Espresso Shot 14000"), # โœ… + ("F27", "Mie Goreng Spesial 23000", "Mle Goreng Spesial 23000"), # โš ๏ธ "Mle" + ("F28", "Coklat Panas 16000", "Coklat Panas 16000"), # โœ… + ("F29", "Mixed Juice 18000", "Mixed Juice 18000"), # โœ… + ("F30", "Brownies Kukus 14000", "Brownies Kukus 14000"), # โœ… +] + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# HITUNG WER & CER PER ITEM +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +rows = [] +for foto_id, truth, ocr_result in data_ocr: + item_wer = wer(truth, ocr_result) + item_cer = cer(truth, ocr_result) + rows.append({ + "Foto": foto_id, + "Ground Truth": truth, + "Hasil OCR": ocr_result, + "WER": round(item_wer, 4), + "CER": round(item_cer, 4), + "WER (%)": f"{item_wer*100:.1f}%", + "CER (%)": f"{item_cer*100:.1f}%", + "Akurasi Kata (%)": f"{(1-item_wer)*100:.1f}%", + }) + +df_ocr = pd.DataFrame(rows) + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# RATA-RATA KESELURUHAN +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +all_truth = [r[1] for r in data_ocr] +all_ocr = [r[2] for r in data_ocr] +avg_wer = wer(all_truth, all_ocr) +avg_cer = cer(all_truth, all_ocr) +avg_acc = (1 - avg_wer) * 100 + +print("="*55) +print(" HASIL UJI AKURASI OCR") +print("="*55) +print(df_ocr[["Foto","WER (%)","CER (%)","Akurasi Kata (%)"]].to_string(index=False)) +print(f"\n Rata-rata WER : {avg_wer*100:.2f}%") +print(f" Rata-rata CER : {avg_cer*100:.2f}%") +print(f" Akurasi OCR : {avg_acc:.2f}%") +print("="*55) + +# Simpan ke Excel +df_ocr.to_excel("hasil_uji_ocr.xlsx", index=False) +print("[OK] Disimpan: hasil_uji_ocr.xlsx") diff --git a/tools/pengujian_ai/hasil_pengujian_template.csv b/tools/pengujian_ai/hasil_pengujian_template.csv new file mode 100644 index 0000000..4e1a44a --- /dev/null +++ b/tools/pengujian_ai/hasil_pengujian_template.csv @@ -0,0 +1,31 @@ +skenario_id,actual_menu_1,actual_price_1,actual_category_1,actual_menu_2,actual_price_2,actual_category_2,actual_menu_3,actual_price_3,actual_category_3,contains_allergen,budget_exceeded,category_match,overall_valid +S01,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S02,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S03,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S04,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S05,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S06,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S07,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S08,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S09,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S10,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S11,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S12,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S13,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S14,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S15,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S16,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S17,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S18,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S19,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S20,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S21,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S22,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S23,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S24,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S25,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S26,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S27,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S28,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S29,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 +S30,[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],[isi nama menu AI],[isi harga],[isi kategori],0,0,1,1 diff --git a/tools/pengujian_ai/hasil_uji_ocr.xlsx b/tools/pengujian_ai/hasil_uji_ocr.xlsx new file mode 100644 index 0000000..02f65ba Binary files /dev/null and b/tools/pengujian_ai/hasil_uji_ocr.xlsx differ diff --git a/tools/pengujian_ai/skenario_pengujian.csv b/tools/pengujian_ai/skenario_pengujian.csv new file mode 100644 index 0000000..3f2693f --- /dev/null +++ b/tools/pengujian_ai/skenario_pengujian.csv @@ -0,0 +1,31 @@ +skenario_id,mood,cuaca,waktu,budget,alergi,diet,keinginan,expected_category,expected_budget_compliant,expected_allergen_free,label_valid +S01,Senang,Cerah,Pagi,25000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S02,Sedih,Hujan,Malam,30000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S03,Lelah,Cerah,Siang,20000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S04,Santai,Dingin,Sore,35000,Tidak ada,Vegetarian,makanan,makanan,1,1,1 +S05,Stres,Hujan,Malam,40000,Kacang,Tidak ada,keduanya,bundling,1,1,1 +S06,Senang,Panas,Siang,15000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S07,Fokus,Cerah,Pagi,25000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S08,Lelah,Dingin,Malam,45000,Susu,Tidak ada,keduanya,bundling,1,1,1 +S09,Santai,Cerah,Sore,30000,Tidak ada,Tidak ada,makanan,makanan,1,1,1 +S10,Energik,Panas,Siang,20000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S11,Sedih,Mendung,Pagi,35000,Tidak ada,Vegetarian,keduanya,bundling,1,1,1 +S12,Senang,Cerah,Sore,50000,Tidak ada,Tidak ada,keduanya,bundling,1,1,1 +S13,Stres,Hujan,Sore,25000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S14,Lelah,Panas,Siang,15000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S15,Santai,Cerah,Pagi,30000,Gluten,Tidak ada,makanan,makanan,1,1,1 +S16,Fokus,Dingin,Malam,40000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S17,Sedih,Hujan,Sore,20000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S18,Energik,Cerah,Pagi,45000,Tidak ada,Tidak ada,keduanya,bundling,1,1,1 +S19,Stres,Mendung,Malam,30000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S20,Senang,Panas,Siang,35000,Tidak ada,Halal,keduanya,bundling,1,1,1 +S21,Santai,Dingin,Sore,25000,Susu,Tidak ada,minuman,minuman,1,1,1 +S22,Lelah,Cerah,Pagi,20000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S23,Sedih,Hujan,Malam,40000,Tidak ada,Vegetarian,makanan,makanan,1,1,1 +S24,Energik,Panas,Siang,30000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S25,Fokus,Cerah,Sore,50000,Tidak ada,Tidak ada,keduanya,bundling,1,1,1 +S26,Stres,Dingin,Pagi,35000,Kacang,Tidak ada,minuman,minuman,1,1,1 +S27,Santai,Mendung,Siang,25000,Tidak ada,Tidak ada,makanan,makanan,1,1,1 +S28,Senang,Cerah,Malam,45000,Tidak ada,Halal,keduanya,bundling,1,1,1 +S29,Lelah,Hujan,Sore,20000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 +S30,Fokus,Panas,Siang,30000,Tidak ada,Tidak ada,minuman,minuman,1,1,1 diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b44e1ac --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + muningasssistant + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..76d6754 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "muningasssistant", + "short_name": "muningasssistant", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..70335ef --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(muningasssistant LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "muningasssistant") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..1dd1a69 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,32 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + CloudFirestorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("CloudFirestorePluginCApi")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseAuthPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FirebaseStoragePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseStoragePluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..a503257 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,30 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + cloud_firestore + connectivity_plus + file_selector_windows + firebase_auth + firebase_core + firebase_storage + permission_handler_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..f806c51 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "muningasssistant" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "muningasssistant" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "muningasssistant.exe" "\0" + VALUE "ProductName", "muningasssistant" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..0b52ead --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"muningasssistant", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_