Compare commits

..

2 Commits

Author SHA1 Message Date
eb12f138b0 Perbaikan tampilan 2026-01-12 23:13:51 +07:00
6c2d32af30 Perbaikan UI/UX 2026-01-12 16:18:35 +07:00
19 changed files with 978 additions and 843 deletions

View File

@ -1,123 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-12T10:12:43.024416Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Ahmar Rafly\.android\avd\Pixel_6_Pro.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

6
.idea/studiobot.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>

View File

@ -1,36 +1,23 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.google.services)
}
// Load properties from local.properties file
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localProperties.load(FileInputStream(localPropertiesFile))
} }
android { android {
namespace = "com.example.ppb_kelompok2" namespace = "com.example.ppb_kelompok2"
compileSdk = 35 compileSdk {
version = release(36)
}
defaultConfig { defaultConfig {
applicationId = "com.example.ppb_kelompok2" applicationId = "com.example.ppb_kelompok2"
minSdk = 25 minSdk = 25
targetSdk = 35 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Expose the API key as a BuildConfig field
// This reads the HF_API_KEY from your local.properties file
buildConfigField("String", "HF_API_KEY", "\"${localProperties.getProperty("HF_API_KEY") ?: ""}\"")
} }
buildTypes { buildTypes {
@ -51,7 +38,6 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true // Ensure this is enabled
} }
} }
@ -66,15 +52,6 @@ dependencies {
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation("androidx.compose.material:material-icons-extended") implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.navigation:navigation-compose:2.9.6") implementation("androidx.navigation:navigation-compose:2.9.6")
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.auth.ktx)
implementation(libs.firebase.firestore.ktx)
implementation(libs.play.services.auth)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.okhttp.logging)
implementation(libs.coil.compose)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@ -1,29 +0,0 @@
{
"project_info": {
"project_number": "532164852718",
"project_id": "jurnal-psikologi",
"storage_bucket": "jurnal-psikologi.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:532164852718:android:efd8ddbb729d947eaeecff",
"android_client_info": {
"package_name": "com.example.ppb_kelompok2"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBnq_cjey_sz1KfJQ1mCJlvK61lWEQATis"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -2,35 +2,30 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<!-- Izin Internet (WAJIB untuk Firebase & API) -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher_mindtrack" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_mindtrack_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.PPB_Kelompok2"> android:theme="@style/Theme.PPB_Kelompok2"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.PPB_Kelompok2"> android:theme="@style/Theme.PPB_Kelompok2"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:name=".ReminderReceiver" android:enabled="true" android:exported="false"/>
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,893 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>PsyJournal React</title>
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/recharts/shadcn-recharts.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body { margin: 0; padding: 0; overflow-x: hidden; }
.pb-safe { padding-bottom: env(safe-area-inset-bottom, 20px); }
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 20px;
width: 20px;
border-radius: 50%;
background: #ffffff;
border: 2px solid currentColor;
margin-top: -8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
// Shim for Lucide Icons in browser
const { useState, useEffect, useRef } = React;
// Mocking Lucide components to work in browser without bundler
const Icon = ({ name, className }) => {
useEffect(() => {
if (window.lucide) {
window.lucide.createIcons();
}
}, [name]);
return <i data-lucide={name.toLowerCase().replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()} className={className}></i>;
};
const BookOpen = (props) => <Icon name="BookOpen" {...props} />;
const Activity = (props) => <Icon name="Activity" {...props} />;
const BrainCircuit = (props) => <Icon name="BrainCircuit" {...props} />;
const User = (props) => <Icon name="User" {...props} />;
const Save = (props) => <Icon name="Save" {...props} />;
const Smile = (props) => <Icon name="Smile" {...props} />;
const Meh = (props) => <Icon name="Meh" {...props} />;
const Frown = (props) => <Icon name="Frown" {...props} />;
const TrendingUp = (props) => <Icon name="TrendingUp" {...props} />;
const AlertTriangle = (props) => <Icon name="AlertTriangle" {...props} />;
const Award = (props) => <Icon name="Award" {...props} />;
const Clock = (props) => <Icon name="Clock" {...props} />;
const Phone = (props) => <Icon name="Phone" {...props} />;
const X = (props) => <Icon name="X" {...props} />;
const Zap = (props) => <Icon name="Zap" {...props} />;
const Search = (props) => <Icon name="Search" {...props} />;
const CheckCircle = (props) => <Icon name="CheckCircle" {...props} />;
const Trash2 = (props) => <Icon name="Trash2" {...props} />;
const Edit2 = (props) => <Icon name="Edit2" {...props} />;
const Plus = (props) => <Icon name="Plus" {...props} />;
const Play = (props) => <Icon name="Play" {...props} />;
const HelpCircle = (props) => <Icon name="HelpCircle" {...props} />;
const ArrowRight = (props) => <Icon name="ArrowRight" {...props} />;
const Moon = (props) => <Icon name="Moon" {...props} />;
const Sun = (props) => <Icon name="Sun" {...props} />;
const Calendar = (props) => <Icon name="Calendar" {...props} />;
const LogOut = (props) => <Icon name="LogOut" {...props} />;
const Globe = (props) => <Icon name="Globe" {...props} />;
// Mocking Recharts for Browser (Simple version if library not fully loaded)
const { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } = window.Recharts || {
ResponsiveContainer: ({children}) => <div style={{width:'100%', height:'100%'}}>{children}</div>,
AreaChart: () => <div className="bg-slate-100 w-full h-full flex items-center justify-center text-xs text-slate-400">Chart Placeholder</div>,
Area: () => null, XAxis: () => null, YAxis: () => null, CartesianGrid: () => null, Tooltip: () => null
};
const PsychologyJournalApp = () => {
// --- KONFIGURASI WARNA & LEVEL ---
const COLORS = {
normal: '#38A169', // Hijau
mild: '#D69E2E', // Kuning/Emas
moderate: '#DD6B20',// Orange
severe: '#E53E3E', // Merah
primary: '#4F6DAD', // Default Biru
bg: '#F5F5F7'
};
// --- STATE MANAGEMENT ---
const [activeTab, setActiveTab] = useState('journal');
const [viewMode, setViewMode] = useState('main');
const [modalBadge, setModalBadge] = useState(null);
const [showHelpModal, setShowHelpModal] = useState(false);
// User Data
const [userProfile, setUserProfile] = useState({
name: 'Pengguna',
streak: 0,
lastActiveDate: null,
xp: 0,
badges: []
});
// Data Jurnal & Penilaian (Mulai Kosong)
const [journals, setJournals] = useState([]);
const [assessments, setAssessments] = useState([]);
// Journal Form States
const [journalMode, setJournalMode] = useState('free');
const [journalText, setJournalText] = useState('');
const [availableTags, setAvailableTags] = useState(['#sedih', '#bangga', '#lelah', '#bersyukur', '#cemas']);
const [journalTags, setJournalTags] = useState([]);
const [tagInput, setTagInput] = useState('');
const [reflectionAnswers, setReflectionAnswers] = useState({ q1: '', q2: '', q3: '' });
const [autoSaveTime, setAutoSaveTime] = useState(null);
const [showAssessmentAlert, setShowAssessmentAlert] = useState(false);
// Search
const [searchQuery, setSearchQuery] = useState('');
// Assessment Inputs
const [assessmentInputs, setAssessmentInputs] = useState({
mood: 0, stress: 0, energy: 0, focus: 0
});
const [reportRange, setReportRange] = useState('mingguan');
// --- GAME STATES ---
const [activeGame, setActiveGame] = useState(null);
// Reaction
const [reactionState, setReactionState] = useState('intro');
const [reactionTime, setReactionTime] = useState(0);
const reactionTimerRef = useRef(null);
const reactionStartRef = useRef(0);
// Simon
const [simonSequence, setSimonSequence] = useState([]);
const [simonUserStep, setSimonUserStep] = useState(0);
const [simonPlaying, setSimonPlaying] = useState(false);
const [simonFlash, setSimonFlash] = useState(null);
const [simonScore, setSimonScore] = useState(0);
const [simonGameOver, setSimonGameOver] = useState(false);
// Logic
const [logicSetup, setLogicSetup] = useState({ difficulty: 'mudah', count: 5 });
const [logicState, setLogicState] = useState('setup');
const [logicQuestions, setLogicQuestions] = useState([]);
const [logicCurrentIdx, setLogicCurrentIdx] = useState(0);
const [logicScore, setLogicScore] = useState(0);
// --- DATABASE LENCANA ---
const BADGES_DB = [
{ id: 'first_step', name: 'Langkah Awal', icon: '🚶', desc: 'Menulis jurnal pertama kali.', date: '-' },
{ id: 'writer_routine', name: 'Penulis Rutin', icon: '✍️', desc: 'Menulis jurnal 7 hari berturut-turut.', date: '-' },
{ id: 'self_discipline', name: 'Disiplin Diri', icon: '🔥', desc: 'Mencapai streak aktivitas 7 hari.', date: '-' },
{ id: 'simon_master', name: 'Master Memori', icon: '🧠', desc: 'Mencapai level 8 di Tes Memori.', date: '-' },
{ id: 'reaction_flash', name: 'Si Kilat', icon: '⚡', desc: 'Reaksi di bawah 250ms.', date: '-' },
{ id: 'logic_einstein', name: 'Logika Einstein', icon: '🎓', desc: 'Skor 100% pada Logika Susah.', date: '-' },
{ id: 'night_owl', name: 'Burung Hantu', icon: '🦉', desc: 'Menulis jurnal di atas jam 10 malam.', date: '-' },
{ id: 'early_bird', name: 'Si Pagi', icon: '🌅', desc: 'Melakukan penilaian sebelum jam 7 pagi.', date: '-' },
{ id: 'zen_mood', name: 'Emosi Zen', icon: '🧘‍♀️', desc: 'Skor kestabilan emosi 0 (Normal) selama 3 hari.', date: '-' },
{ id: 'story_teller', name: 'Penyair Hati', icon: '📜', desc: 'Menulis jurnal lebih dari 500 karakter.', date: '-' },
];
// --- LOGIC FUNCTIONS ---
useEffect(() => {
if (!journalText && !reflectionAnswers.q1) return;
const timer = setTimeout(() => {
setAutoSaveTime(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
}, 30000);
return () => clearTimeout(timer);
}, [journalText, reflectionAnswers]);
const getTodayStr = () => {
const d = new Date();
return d.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
};
const hasAssessedToday = () => assessments.some(a => a.date === getTodayStr());
const updateStreak = () => {
const today = getTodayStr();
if (userProfile.lastActiveDate === today) return;
setUserProfile(prev => ({
...prev,
streak: prev.streak + 1,
lastActiveDate: today,
xp: prev.xp + 20
}));
if (userProfile.streak === 0) unlockBadge('first_step');
};
const checkTimeBadges = () => {
const hour = new Date().getHours();
if (hour >= 22 || hour < 4) unlockBadge('night_owl');
if (hour >= 4 && hour < 7) unlockBadge('early_bird');
};
const handleAddTag = () => {
if (!tagInput.trim()) return;
const newTag = tagInput.startsWith('#') ? tagInput.trim() : `#${tagInput.trim()}`;
if (!availableTags.includes(newTag)) setAvailableTags([...availableTags, newTag]);
if (!journalTags.includes(newTag)) setJournalTags([...journalTags, newTag]);
setTagInput('');
};
const handleTagClick = (tag) => {
setSearchQuery(tag);
setViewMode('history_full');
};
const handleSaveJournal = () => {
if (!journalText.trim() && journalMode === 'free') {
alert("⚠️ Jurnal Bebas masih kosong.");
return;
}
if ((!reflectionAnswers.q1.trim() || !reflectionAnswers.q2.trim() || !reflectionAnswers.q3.trim()) && journalMode === 'reflection') {
alert("⚠️ Refleksi Harian belum lengkap.");
return;
}
const newEntry = {
id: Date.now(),
date: getTodayStr(),
type: 'complete',
content: {
text: journalText,
reflection: { ...reflectionAnswers }
},
tags: [...journalTags]
};
if (journalText.length > 500) unlockBadge('story_teller');
checkTimeBadges();
setJournals(prev => [newEntry, ...prev]);
setJournalText('');
setReflectionAnswers({ q1: '', q2: '', q3: '' });
setJournalTags([]);
setAutoSaveTime(null);
updateStreak();
if (!hasAssessedToday()) setShowAssessmentAlert(true);
else alert("✅ Jurnal Berhasil Disimpan!");
};
const handleSaveAssessment = () => {
const total = Object.values(assessmentInputs).reduce((a, b) => a + b, 0);
setAssessments(prev => [{ id: Date.now(), date: getTodayStr(), scores: assessmentInputs, total }, ...prev]);
if (total >= 10) alert("⚠️ Skor Berat. Disarankan konsultasi.");
checkTimeBadges();
if (assessmentInputs.mood === 0) unlockBadge('zen_mood');
updateStreak();
setAssessmentInputs({ mood: 0, stress: 0, energy: 0, focus: 0 });
setActiveTab('profile');
};
const handleDeleteAccount = () => {
if (window.confirm("Yakin hapus akun permanen?")) {
setJournals([]);
setAssessments([]);
setUserProfile({ name: 'Pengguna Baru', streak: 0, lastActiveDate: null, xp: 0, badges: [] });
setActiveTab('journal');
}
};
const unlockBadge = (badgeId) => {
if (!userProfile.badges.includes(badgeId)) {
setUserProfile(prev => ({ ...prev, badges: [...prev.badges, badgeId] }));
setModalBadge(BADGES_DB.find(b => b.id === badgeId));
}
};
const getSeverityInfo = (score) => {
if (score >= 10) return { color: COLORS.severe, bg: 'bg-red-50', text: 'text-red-700', fill: 'bg-red-500', label: 'Berat' };
if (score >= 7) return { color: COLORS.moderate, bg: 'bg-orange-50', text: 'text-orange-700', fill: 'bg-orange-500', label: 'Sedang' };
if (score >= 4) return { color: COLORS.mild, bg: 'bg-yellow-50', text: 'text-yellow-700', fill: 'bg-yellow-500', label: 'Ringan' };
return { color: COLORS.normal, bg: 'bg-green-50', text: 'text-green-700', fill: 'bg-green-500', label: 'Normal' };
};
const getChartData = () => {
if (assessments.length === 0) {
if (reportRange === 'mingguan') {
return ['Sn', 'Sl', 'Rb', 'Km', 'Jm', 'Sb', 'Mg'].map(day => ({ name: day, score: 0 }));
} else if (reportRange === 'bulanan') {
return ['M1', 'M2', 'M3', 'M4'].map(week => ({ name: week, score: 0 }));
} else {
return ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'].map(month => ({ name: month, score: 0 }));
}
}
const latestScore = assessments[0].total;
const jitter = (val) => Math.max(0, Math.min(12, val + (Math.random() * 2 - 1)));
if (reportRange === 'mingguan') {
return ['Sn', 'Sl', 'Rb', 'Km', 'Jm', 'Sb', 'Mg'].map(day => ({ name: day, score: jitter(latestScore) }));
} else if (reportRange === 'bulanan') {
return ['M1', 'M2', 'M3', 'M4'].map(week => ({ name: week, score: jitter(latestScore) }));
} else {
return ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'].map(month => ({ name: month, score: jitter(latestScore) }));
}
};
// --- GAME LOGIC ---
const startReactionGame = () => {
setReactionState('waiting');
setReactionTime(0);
const delay = 2000 + Math.random() * 4000;
reactionTimerRef.current = setTimeout(() => { setReactionState('ready'); reactionStartRef.current = Date.now(); }, delay);
};
const handleReactionClick = () => {
if (reactionState === 'waiting') { clearTimeout(reactionTimerRef.current); setReactionState('too-soon'); }
else if (reactionState === 'ready') {
const time = Date.now() - reactionStartRef.current;
setReactionTime(time); setReactionState('finished');
if (time < 250) unlockBadge('reaction_flash');
}
};
const playSimonSequence = async (sequence) => {
setSimonPlaying(false);
for (let i = 0; i < sequence.length; i++) {
await new Promise(r => setTimeout(r, 500));
setSimonFlash(sequence[i]);
await new Promise(r => setTimeout(r, 500));
setSimonFlash(null);
}
setSimonPlaying(true);
};
const startSimonGame = () => {
const firstIdx = Math.floor(Math.random() * 9);
const newSeq = [firstIdx];
setSimonSequence(newSeq); setSimonScore(0); setSimonUserStep(0); setSimonGameOver(false);
playSimonSequence(newSeq);
};
const handleSimonClick = (index) => {
if (!simonPlaying || simonGameOver) return;
setSimonFlash(index); setTimeout(() => setSimonFlash(null), 200);
if (index === simonSequence[simonUserStep]) {
if (simonUserStep === simonSequence.length - 1) {
const nextScore = simonScore + 1; setSimonScore(nextScore); setSimonUserStep(0);
const newSeq = [...simonSequence, Math.floor(Math.random() * 9)]; setSimonSequence(newSeq);
if (nextScore === 8) unlockBadge('simon_master');
setTimeout(() => playSimonSequence(newSeq), 1000);
} else { setSimonUserStep(simonUserStep + 1); }
} else { setSimonGameOver(true); setSimonPlaying(false); }
};
const LOGIC_DB = {
mudah: [{ q: "2, 4, 6, ...?", options: ["7","8","9"], ans: 1 }, { q: "Lawan kata Panas?", options: ["Dingin","Hangat","Api"], ans: 0 }, { q: "Warna langit cerah?", options: ["Merah","Biru","Hijau"], ans: 1 }, { q: "1 + 1 x 0 = ?", options: ["0","1","2"], ans: 1 }, { q: "Bentuk Roda?", options: ["Kotak","Bulat","Segitiga"], ans: 1 }],
sedang: [{ q: "1, 1, 2, 3, ...?", options: ["4","5","6"], ans: 1 }],
susah: [{ q: "A>B, B>C, A...C?", options: [">","<","="], ans: 0 }]
};
const startLogicGame = () => {
const pool = LOGIC_DB[logicSetup.difficulty];
setLogicQuestions(pool); setLogicCurrentIdx(0); setLogicScore(0); setLogicState('playing');
};
const handleLogicAnswer = (idx) => {
if (idx === logicQuestions[logicCurrentIdx].ans) setLogicScore(logicScore + 1);
if (logicCurrentIdx < logicQuestions.length - 1) {
setLogicCurrentIdx(logicCurrentIdx + 1);
} else {
setLogicState('finished');
if (logicSetup.difficulty === 'susah' && logicScore === logicQuestions.length - 1) unlockBadge('logic_einstein');
}
};
// --- RENDERERS ---
const renderJournal = () => (
<div className="flex flex-col h-[calc(100vh-170px)] animate-fade-in">
<div className="bg-white p-1 rounded-xl flex shadow-sm border border-slate-100 mb-4 shrink-0">
<button onClick={() => setJournalMode('free')} className={`flex-1 py-3 text-sm font-bold rounded-lg transition-all ${journalMode === 'free' ? 'bg-[#4F6DAD] text-white shadow-md' : 'text-slate-400 hover:bg-slate-50'}`}>Jurnal Bebas</button>
<button onClick={() => setJournalMode('reflection')} className={`flex-1 py-3 text-sm font-bold rounded-lg transition-all ${journalMode === 'reflection' ? 'bg-[#4F6DAD] text-white shadow-md' : 'text-slate-400 hover:bg-slate-50'}`}>Refleksi Harian</button>
</div>
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 relative flex-1 flex flex-col">
{journalMode === 'free' ? (
<>
<textarea
value={journalText}
onChange={(e) => setJournalText(e.target.value)}
placeholder="Tuliskan perasaanmu..."
className="w-full flex-1 bg-[#F5F5F7] p-4 rounded-2xl border border-slate-200 outline-none resize-none text-slate-700 focus:ring-2 focus:ring-[#4F6DAD] focus:border-transparent transition-all mb-4"
></textarea>
<div className="shrink-0">
<div className="flex items-center gap-2 mb-3">
<input type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} placeholder="+ Tag..." className="text-xs p-2 rounded-lg bg-[#F5F5F7] border border-slate-200 outline-none w-36" />
<button onClick={handleAddTag} disabled={!tagInput.trim()} className="p-2 bg-slate-100 rounded-lg text-slate-500 hover:bg-[#4F6DAD] hover:text-white disabled:opacity-50 transition-colors"><Plus className="w-3 h-3" /></button>
</div>
<div className="flex gap-2 mb-4 overflow-x-auto pb-2 scrollbar-hide">
{availableTags.map(tag => (
<button key={tag} onClick={() => setJournalTags(prev => prev.includes(tag) ? prev.filter(t=>t!==tag) : [...prev, tag])} className={`px-3 py-1 rounded-full text-xs font-bold transition-colors whitespace-nowrap ${journalTags.includes(tag) ? 'bg-[#4F6DAD] text-white' : 'bg-slate-100 text-slate-400'}`}>{tag}</button>
))}
</div>
</div>
</>
) : (
<div className="flex-1 overflow-y-auto px-1 space-y-5">
{[
{
q: 'Satu hal kecil yang membuatmu tersenyum hari ini?',
key: 'q1',
icon: Smile,
color: 'text-yellow-600 bg-yellow-100',
ph: 'Misal: Kopi pagi yang enak, sapaan teman...'
},
{
q: 'Tantangan terbesar hari ini & solusinya?',
key: 'q2',
icon: Activity,
color: 'text-red-600 bg-red-100',
ph: 'Misal: Macet total, solusinya dengar podcast...'
},
{
q: 'Satu hal yang ingin kamu perbaiki besok?',
key: 'q3',
icon: TrendingUp,
color: 'text-blue-600 bg-blue-100',
ph: 'Misal: Tidur lebih awal, kurangi sosmed...'
}
].map((item, idx) => (
<div key={idx} className="group relative">
<div className="flex items-center gap-2 mb-2">
<div className={`p-1.5 rounded-lg ${item.color}`}>
<item.icon className="w-4 h-4"/>
</div>
<label className="text-xs font-bold text-slate-700 uppercase tracking-wide">{item.q}</label>
</div>
<textarea
value={reflectionAnswers[item.key]}
onChange={(e) => setReflectionAnswers({...reflectionAnswers, [item.key]: e.target.value})}
className="w-full p-4 bg-[#F5F5F7] rounded-2xl border border-slate-200 outline-none focus:ring-2 focus:ring-[#4F6DAD] focus:bg-white focus:border-transparent transition-all resize-none text-slate-700 text-sm placeholder:text-slate-400"
placeholder={item.ph}
rows={3}
/>
</div>
))}
</div>
)}
<div className="shrink-0 mt-3 flex flex-col gap-3 pt-3 border-t border-slate-50">
{autoSaveTime && <div className="flex items-center justify-end gap-1.5 text-[10px] text-slate-400 font-medium animate-fade-in"><CheckCircle className="w-3 h-3 text-green-500" /> Disimpan otomatis {autoSaveTime}</div>}
<button onClick={handleSaveJournal} className="w-full bg-[#4F6DAD] text-white py-4 rounded-2xl font-bold text-lg hover:shadow-lg hover:bg-[#3E5C9A] active:scale-[0.98] transition-all flex items-center justify-center gap-2 shadow-md"><Save className="w-5 h-5"/> Simpan Jurnal</button>
</div>
</div>
{showAssessmentAlert && (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-6 animate-fade-in">
<div className="bg-white p-6 rounded-3xl shadow-2xl max-w-sm w-full text-center">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4"><Activity className="w-8 h-8 text-yellow-600"/></div>
<h3 className="text-xl font-bold text-slate-800 mb-2">Lupa Sesuatu?</h3>
<p className="text-slate-500 mb-6">Anda belum menilai mood hari ini.</p>
<div className="flex gap-3">
<button onClick={() => setShowAssessmentAlert(false)} className="flex-1 py-3 text-slate-400 font-bold hover:bg-slate-50 rounded-xl">Nanti Saja</button>
<button onClick={() => {setShowAssessmentAlert(false); setActiveTab('assessment')}} className="flex-1 py-3 bg-[#4F6DAD] text-white font-bold rounded-xl shadow-lg">Ya, Lanjut</button>
</div>
</div>
</div>
)}
</div>
);
const renderAssessment = () => {
const total = Object.values(assessmentInputs).reduce((a, b) => a + b, 0);
const risk = getSeverityInfo(total);
const indicators = [{ id: 'mood', label: 'Emosi' }, { id: 'stress', label: 'Stress' }, { id: 'energy', label: 'Energi' }, { id: 'focus', label: 'Fokus' }];
return (
<div className="space-y-6 animate-fade-in pb-20">
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 sticky top-20 z-10">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-bold text-slate-700">Skor Mental</h2>
<span className={`px-3 py-1 rounded-lg text-xs font-bold ${risk.bg} ${risk.text}`}>{total} - {risk.label}</span>
</div>
<div className="w-full bg-slate-100 h-2 rounded-full mt-3 overflow-hidden">
<div className={`h-full transition-all duration-500 ${total >= 10 ? 'bg-red-500' : total >= 7 ? 'bg-orange-500' : total >= 4 ? 'bg-yellow-500' : 'bg-[#38A169]'}`} style={{width: `${(total/12)*100}%`}}></div>
</div>
</div>
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 space-y-8">
{indicators.map((item) => {
const val = assessmentInputs[item.id];
const getSeverity = (v) => {
if(v===0) return {label:'Normal', hex: COLORS.normal};
if(v===1) return {label:'Ringan', hex: COLORS.mild};
if(v===2) return {label:'Sedang', hex: COLORS.moderate};
return {label:'Berat', hex: COLORS.severe};
};
const sev = getSeverity(val);
const percent = (val / 3) * 100;
return (
<div key={item.id} className="mb-6">
<div className="flex justify-between mb-2">
<label className="font-bold text-slate-700">{item.label}</label>
<span className="text-sm font-bold" style={{color: sev.hex}}>{sev.label}</span>
</div>
<div className="relative h-10 flex items-center">
<div className="absolute w-full h-3 bg-slate-200 rounded-full"></div>
<div
className="absolute h-3 rounded-full transition-all duration-300"
style={{
width: `${percent}%`,
background: `linear-gradient(to right, ${sev.hex}, ${sev.hex})`
}}
></div>
<div
className="absolute h-8 w-8 rounded-full shadow-md border-4 border-white flex items-center justify-center text-xs font-bold text-white transition-all duration-300 z-10"
style={{
left: `${percent}%`,
transform: `translateX(-50%)`,
backgroundColor: sev.hex
}}
>
{val}
</div>
<input
type="range"
min="0"
max="3"
step="1"
value={val}
onChange={(e) => setAssessmentInputs(prev => ({...prev, [item.id]: parseInt(e.target.value)}))}
className="absolute w-full h-full opacity-0 cursor-pointer z-20"
/>
</div>
<div className="flex justify-between text-[10px] text-slate-400 -mt-1 font-medium px-1">
<span>Normal</span><span>Berat</span>
</div>
</div>
);
})}
<button onClick={handleSaveAssessment} className="w-full py-4 bg-[#4F6DAD] text-white rounded-xl font-bold text-lg hover:bg-[#3E5C9A] transition-all shadow-lg">Simpan Penilaian</button>
</div>
</div>
);
};
const renderCognitive = () => (
<div className="space-y-6 animate-fade-in pb-20">
{!activeGame && (
<div className="grid grid-cols-1 gap-4">
<button onClick={() => { setActiveGame('simon'); startSimonGame(); }} className="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-all text-left flex gap-4 items-center">
<div className="p-3 bg-purple-100 text-purple-600 rounded-xl"><BrainCircuit className="w-6 h-6"/></div>
<div><h3 className="font-bold text-slate-800">Tes Memori</h3><p className="text-xs text-slate-400">Ingat urutan pola grid 3x3</p></div>
</button>
<button onClick={() => { setActiveGame('reaction'); setReactionState('intro'); }} className="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-all text-left flex gap-4 items-center">
<div className="p-3 bg-yellow-100 text-yellow-600 rounded-xl"><Zap className="w-6 h-6"/></div>
<div><h3 className="font-bold text-slate-800">Kecepatan Reaksi</h3><p className="text-xs text-slate-400">Tes refleks visual</p></div>
</button>
<button onClick={() => { setActiveGame('logic'); setLogicState('setup'); }} className="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm hover:shadow-md transition-all text-left flex gap-4 items-center">
<div className="p-3 bg-blue-100 text-blue-600 rounded-xl"><Search className="w-6 h-6"/></div>
<div><h3 className="font-bold text-slate-800">Tes Logika</h3><p className="text-xs text-slate-400">Asah pola pikir</p></div>
</button>
</div>
)}
{/* GAME: REACTION */}
{activeGame === 'reaction' && (
<div className="bg-white p-6 rounded-3xl shadow-lg text-center h-[400px] flex flex-col justify-center relative">
<button onClick={() => setActiveGame(null)} className="absolute top-4 right-4 p-2 bg-slate-100 rounded-full"><X className="w-4 h-4"/></button>
<h3 className="font-bold text-xl mb-4 text-[#4F6DAD]">Tes Reaksi</h3>
<div onClick={handleReactionClick} className={`flex-1 rounded-2xl flex flex-col items-center justify-center cursor-pointer transition-all select-none ${reactionState === 'intro' ? 'bg-slate-100' : reactionState === 'waiting' ? 'bg-red-500' : reactionState === 'ready' ? 'bg-green-500' : reactionState === 'too-soon' ? 'bg-orange-500' : 'bg-[#4F6DAD]'}`}>
{reactionState === 'intro' && <button onClick={(e) => { e.stopPropagation(); startReactionGame(); }} className="bg-[#4F6DAD] text-white px-8 py-3 rounded-full font-bold shadow-lg">Mulai Tes</button>}
{reactionState === 'waiting' && <p className="text-white font-bold text-2xl animate-pulse">Tunggu Hijau...</p>}
{reactionState === 'ready' && <p className="text-white font-bold text-3xl">TEKAN!</p>}
{reactionState === 'too-soon' && <div onClick={(e) => e.stopPropagation()}><p className="text-white font-bold mb-4">Terlalu Cepat!</p><button onClick={startReactionGame} className="bg-white/30 text-white px-4 py-2 rounded-xl">Ulangi</button></div>}
{reactionState === 'finished' && <div onClick={(e) => e.stopPropagation()}><p className="text-white font-bold text-4xl mb-2">{reactionTime} ms</p><button onClick={startReactionGame} className="bg-white/20 text-white px-6 py-2 rounded-xl mt-4">Coba Lagi</button></div>}
</div>
<p className="text-xs text-slate-400 mt-4">Jeda waktu berubah setiap ronde.</p>
</div>
)}
{/* GAME: MEMORY TEST (SIMON 3x3) */}
{activeGame === 'simon' && (
<div className="bg-white p-6 rounded-3xl shadow-lg text-center relative">
<button onClick={() => setActiveGame(null)} className="absolute top-4 right-4 p-2 bg-slate-100 rounded-full"><X className="w-4 h-4"/></button>
<div className="mb-6">
<h3 className="font-bold text-xl text-[#4F6DAD]">Tes Memori</h3>
<p className="text-sm text-slate-500">Skor: {simonScore}</p>
</div>
{simonGameOver ? (
<div className="py-10">
<p className="text-red-500 font-bold text-xl mb-4">Game Over!</p>
<p className="text-slate-600 mb-6">Skor Akhir: {simonScore}</p>
<button onClick={startSimonGame} className="bg-[#4F6DAD] text-white px-8 py-3 rounded-xl font-bold">Main Lagi</button>
</div>
) : (
<div className="grid grid-cols-3 gap-3 max-w-[280px] mx-auto">
{[...Array(9)].map((_, i) => (
<button
key={i}
onClick={() => handleSimonClick(i)}
className={`h-20 rounded-xl transition-all duration-150 shadow-sm border-b-4 active:border-b-0 active:translate-y-1 ${
simonFlash === i
? 'bg-[#4F6DAD] border-[#3b5488] brightness-110 scale-105' // Active Blue
: 'bg-slate-200 border-slate-300 hover:bg-slate-300' // Default Gray
}`}
></button>
))}
</div>
)}
{!simonPlaying && !simonGameOver && <p className="mt-6 text-sm font-bold text-slate-400 animate-pulse">Perhatikan Urutan...</p>}
{simonPlaying && !simonGameOver && <p className="mt-6 text-sm font-bold text-[#4F6DAD]">Giliranmu!</p>}
</div>
)}
{/* GAME: LOGIC */}
{activeGame === 'logic' && (
<div className="bg-white p-6 rounded-3xl shadow-lg relative min-h-[400px] flex flex-col">
<button onClick={() => setActiveGame(null)} className="absolute top-4 right-4 p-2 bg-slate-100 rounded-full"><X className="w-4 h-4"/></button>
{logicState === 'setup' && (
<div className="flex flex-col justify-center flex-1">
<h3 className="font-bold text-xl text-[#4F6DAD] mb-6 text-center">Pengaturan Tes Logika</h3>
<div className="mb-6">
<label className="text-xs font-bold text-slate-400 uppercase mb-2 block">Tingkat Kesulitan</label>
<div className="flex bg-slate-100 p-1 rounded-xl">
{['mudah', 'sedang', 'susah'].map(lvl => (
<button key={lvl} onClick={() => setLogicSetup({...logicSetup, difficulty: lvl})} className={`flex-1 py-2 rounded-lg text-xs font-bold capitalize transition-all ${logicSetup.difficulty === lvl ? 'bg-white shadow text-[#4F6DAD]' : 'text-slate-400'}`}>{lvl}</button>
))}
</div>
</div>
<div className="mb-8">
<label className="text-xs font-bold text-slate-400 uppercase mb-2 block">Jumlah Soal</label>
<div className="flex gap-4">
{[5, 10].map(count => (
<button key={count} onClick={() => setLogicSetup({...logicSetup, count})} className={`flex-1 py-3 border-2 rounded-xl font-bold transition-all ${logicSetup.count === count ? 'border-[#4F6DAD] text-[#4F6DAD] bg-indigo-50' : 'border-slate-200 text-slate-400'}`}>{count} Soal</button>
))}
</div>
</div>
<button onClick={startLogicGame} className="w-full bg-[#4F6DAD] text-white py-4 rounded-xl font-bold text-lg shadow-lg">Mulai Tes</button>
</div>
)}
{logicState === 'playing' && (
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-6">
<span className="text-xs font-bold text-slate-400">Soal {logicCurrentIdx + 1}/{logicQuestions.length}</span>
<span className="text-xs font-bold bg-indigo-50 text-[#4F6DAD] px-2 py-1 rounded capitalize">{logicSetup.difficulty}</span>
</div>
<h4 className="font-bold text-lg text-slate-800 mb-8 leading-relaxed">{logicQuestions[logicCurrentIdx]?.q}</h4>
<div className="space-y-3 flex-1">
{logicQuestions[logicCurrentIdx]?.options.map((opt, idx) => (
<button key={idx} onClick={() => handleLogicAnswer(idx)} className="w-full text-left p-4 rounded-xl border border-slate-200 hover:bg-indigo-50 hover:border-[#4F6DAD] transition-all font-medium text-slate-600 active:scale-[0.98]">
{opt}
</button>
))}
</div>
</div>
)}
{logicState === 'finished' && (
<div className="text-center flex flex-col justify-center flex-1">
<div className="w-20 h-20 bg-indigo-100 rounded-full flex items-center justify-center mx-auto mb-4 text-[#4F6DAD] text-4xl font-bold">{Math.round((logicScore/logicQuestions.length)*100)}%</div>
<h3 className="font-bold text-xl text-slate-800 mb-2">Tes Selesai!</h3>
<p className="text-slate-500 mb-8">Anda menjawab {logicScore} benar dari {logicQuestions.length} soal.</p>
<button onClick={() => setLogicState('setup')} className="bg-[#4F6DAD] text-white px-8 py-3 rounded-xl font-bold">Ulangi Tes</button>
</div>
)}
</div>
)}
</div>
);
const renderProfile = () => {
const chartData = getChartData();
const avgScore = chartData.length > 0 && assessments.length > 0
? chartData.reduce((a, b) => a + (b.score || 0), 0) / chartData.length
: 0;
const severityInfo = getSeverityInfo(avgScore);
return (
<div className="space-y-6 animate-fade-in pb-20">
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 flex items-center justify-between">
<div className="flex items-center gap-4"><div className="w-14 h-14 bg-[#4F6DAD] rounded-full flex items-center justify-center text-white text-xl font-bold">U</div><div><h2 className="font-bold text-lg text-slate-800">{userProfile.name}</h2><p className="text-xs text-slate-400">Bergabung Jan 2024</p></div></div>
<div className="text-center"><div className="flex items-center gap-1 text-orange-500 font-bold text-xl">{userProfile.streak} <span className="text-2xl">🔥</span></div><p className="text-[10px] text-slate-400 uppercase font-bold tracking-wider">Streak</p></div>
</div>
<button onClick={() => setShowHelpModal(true)} className="w-full bg-red-50 border border-red-100 text-red-600 py-3 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-red-100 transition-colors">
<Phone className="w-4 h-4" /> BUTUH BANTUAN DARURAT?
</button>
{/* 1. LENCANA PENCAPAIAN */}
<div>
<h3 className="font-bold text-slate-700 mb-3 flex items-center gap-2"><Award className="w-5 h-5 text-[#4F6DAD]"/> Lencana</h3>
<div className="bg-white p-4 rounded-3xl shadow-sm border border-slate-100"><div className="grid grid-cols-3 gap-4 max-h-64 overflow-y-auto pr-1">{BADGES_DB.map(badge => (<div key={badge.id} onClick={() => setModalBadge(badge)} className={`aspect-square rounded-2xl flex flex-col items-center justify-center text-center p-2 cursor-pointer transition-all ${userProfile.badges.includes(badge.id) ? 'bg-[#F5F5F7] hover:bg-slate-200' : 'opacity-40 grayscale bg-slate-50'}`}><span className="text-3xl mb-1">{badge.icon}</span><span className="text-[10px] font-bold text-slate-600 leading-tight">{badge.name}</span></div>))}</div></div>
</div>
{/* 2. LAPORAN KESEHATAN */}
<div className="bg-white p-6 rounded-3xl shadow-sm border border-slate-100">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-slate-700">Laporan Kesehatan</h3>
<select value={reportRange} onChange={(e) => setReportRange(e.target.value)} className="bg-[#F5F5F7] text-xs font-bold text-slate-600 py-2 px-3 rounded-lg border-none outline-none">
<option value="mingguan">Mingguan</option>
<option value="bulanan">Bulanan</option>
<option value="tahunan">Tahunan</option>
</select>
</div>
<div className="h-40 w-full mb-4">
<ResponsiveContainer>
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorScore" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={severityInfo.color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={severityInfo.color} stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0"/>
<XAxis dataKey="name" tick={{fontSize:10}} axisLine={false} tickLine={false}/>
<Tooltip contentStyle={{borderRadius:'10px', border:'none'}}/>
<Area type="monotone" dataKey="score" stroke={severityInfo.color} strokeWidth={3} fill="url(#colorScore)"/>
</AreaChart>
</ResponsiveContainer>
</div>
{assessments.length > 0 || journals.length > 0 ? (
<div className={`${severityInfo.bg} p-4 rounded-xl flex gap-3 items-start transition-colors duration-500`}>
<BrainCircuit className={`w-5 h-5 ${severityInfo.text} mt-0.5 flex-shrink-0`}/>
<div>
<h4 className={`text-xs font-bold ${severityInfo.text} uppercase mb-1`}>AI Insight: {severityInfo.label}</h4>
<p className={`text-xs ${severityInfo.text} leading-relaxed opacity-90`}>
{avgScore >= 10 ? "Tingkat stres sangat tinggi. Segera hubungi bantuan profesional atau orang terdekat." :
avgScore >= 7 ? "Terdeteksi beban emosi sedang. Luangkan waktu untuk hobi atau meditasi." :
avgScore >= 4 ? "Ada sedikit gejolak. Tetap jaga pola tidur dan aktivitas fisik." :
"Kondisi mental Anda stabil dan sehat. Teruskan kebiasaan baik ini!"}
</p>
</div>
</div>
) : (
<div className="bg-slate-50 p-4 rounded-xl flex gap-3 items-center text-slate-400">
<BrainCircuit className="w-5 h-5"/>
<p className="text-xs">Isi penilaian untuk melihat analisis AI.</p>
</div>
)}
</div>
{/* 3. RIWAYAT JURNAL TERBARU (DIBAWAH) */}
<div>
<h3 className="font-bold text-slate-700 mb-3 flex items-center gap-2"><BookOpen className="w-5 h-5 text-[#4F6DAD]"/> Riwayat Jurnal Terbaru</h3>
{journals.length > 0 ? (
<div className="space-y-3">
{journals.slice(0, 3).map(j => (
<div key={j.id} className="bg-white p-4 rounded-2xl shadow-sm border border-slate-100">
<div className="flex justify-between mb-1">
<span className="text-xs font-bold text-[#4F6DAD]">{j.date}</span>
</div>
<p className="text-sm text-slate-600 line-clamp-1">{j.content.text}</p>
</div>
))}
<button onClick={() => setViewMode('history_full')} className="w-full py-3 text-[#4F6DAD] font-bold text-xs bg-indigo-50 rounded-xl hover:bg-indigo-100">Lihat Semua</button>
</div>
) : (
<div className="bg-white p-6 rounded-2xl border border-dashed border-slate-200 text-center text-slate-400 text-sm">
Belum ada jurnal yang disimpan.
</div>
)}
</div>
{/* 4. HAPUS AKUN (PALING BAWAH) */}
<div className="pt-4">
<button onClick={handleDeleteAccount} className="w-full py-4 text-red-500 font-bold text-sm bg-red-50 rounded-2xl border border-red-100 hover:bg-red-100 transition-colors flex items-center justify-center gap-2">
<Trash2 className="w-4 h-4"/> Hapus Akun Permanen
</button>
</div>
</div>
);
};
const renderHistoryFull = () => {
const filteredJournals = journals.filter(j => {
const q = searchQuery.toLowerCase();
if (j.type === 'complete' && j.content.text.toLowerCase().includes(q)) return true;
if (j.tags && j.tags.some(t => t.toLowerCase().includes(q))) return true;
return false;
});
return (
<div className="animate-slide-up pb-20">
<div className="sticky top-0 bg-[#F5F5F7] pt-4 pb-2 z-10 flex items-center gap-3 mb-4"><button onClick={() => setViewMode('main')} className="p-2 bg-white rounded-full shadow-sm"><X className="w-5 h-5"/></button><h2 className="text-xl font-bold text-slate-800">Riwayat Jurnal</h2></div>
<div className="relative mb-6"><Search className="absolute left-4 top-3.5 w-5 h-5 text-slate-400" /><input type="text" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Cari jurnal atau tag..." className="w-full pl-12 pr-4 py-3 rounded-xl border border-slate-200 focus:border-[#4F6DAD] outline-none shadow-sm"/></div>
<div className="space-y-4">
{filteredJournals.length > 0 ? filteredJournals.map(j => (
<div key={j.id} className="bg-white p-5 rounded-2xl shadow-sm border border-slate-100">
<div className="flex justify-between items-start mb-2"><span className="text-xs font-bold text-[#4F6DAD] bg-indigo-50 px-2 py-1 rounded-md">{j.date}</span></div>
<div className="space-y-4">
<p className="text-slate-700 text-sm leading-relaxed border-b border-slate-100 pb-3">{j.content.text}</p>
<div className="bg-slate-50 p-4 rounded-xl space-y-3">
<div className="flex items-center gap-2 mb-1"><div className="w-1.5 h-1.5 rounded-full bg-[#4F6DAD]"></div><span className="text-xs font-bold text-slate-500 uppercase">Hal Kecil</span></div><p className="text-sm text-slate-600 pl-3.5 italic">"{j.content.reflection.q1}"</p>
<div className="flex items-center gap-2 mb-1 mt-3"><div className="w-1.5 h-1.5 rounded-full bg-red-400"></div><span className="text-xs font-bold text-slate-500 uppercase">Tantangan</span></div><p className="text-sm text-slate-600 pl-3.5 italic">"{j.content.reflection.q2}"</p>
<div className="flex items-center gap-2 mb-1 mt-3"><div className="w-1.5 h-1.5 rounded-full bg-blue-400"></div><span className="text-xs font-bold text-slate-500 uppercase">Perbaikan</span></div><p className="text-sm text-slate-600 pl-3.5 italic">"{j.content.reflection.q3}"</p>
</div>
</div>
{j.tags && <div className="flex gap-2 mt-3">{j.tags.map(t => <span key={t} onClick={() => handleTagClick(t)} className="text-[10px] text-slate-500 bg-slate-100 px-2 py-1 rounded-full cursor-pointer hover:bg-[#4F6DAD] hover:text-white transition-colors">{t}</span>)}</div>}
</div>
)) : <div className="text-center py-10 text-slate-400"><p>Tidak ditemukan.</p></div>}
</div>
</div>
);
};
if (viewMode === 'history_full') return <div className="min-h-screen bg-[#F5F5F7] font-sans text-[#2D3748] max-w-md mx-auto p-6 relative">{renderHistoryFull()}</div>;
return (
<div className="min-h-screen bg-[#F5F5F7] font-sans text-[#2D3748] max-w-md mx-auto relative shadow-2xl overflow-hidden">
<header className="bg-white px-6 pt-8 pb-4 shadow-sm flex justify-between items-center sticky top-0 z-20">
<div className="flex items-center gap-2"><div className="bg-[#4F6DAD] p-2 rounded-lg text-white"><Activity className="w-5 h-5" /></div><h1 className="text-lg font-extrabold text-[#4F6DAD]">PsyJournal</h1></div>
<div className="w-8 h-8 rounded-full bg-slate-100 border border-slate-200 overflow-hidden"><User className="w-full h-full p-1 text-slate-400"/></div>
</header>
<main className="p-4">
{activeTab === 'journal' && renderJournal()}
{activeTab === 'assessment' && renderAssessment()}
{activeTab === 'cognitive' && renderCognitive()}
{activeTab === 'profile' && renderProfile()}
</main>
<nav className="fixed bottom-0 left-0 right-0 max-w-md mx-auto bg-white border-t border-slate-200 px-6 py-3 flex justify-between items-center z-30 pb-safe">
{[{ id: 'journal', icon: BookOpen, label: 'Jurnal' }, { id: 'assessment', icon: Activity, label: 'Penilaian' }, { id: 'cognitive', icon: BrainCircuit, label: 'Tes' }, { id: 'profile', icon: User, label: 'Profil' }].map((item) => (
<button key={item.id} onClick={() => setActiveTab(item.id)} className={`flex flex-col items-center gap-1 transition-all ${activeTab === item.id ? 'text-[#4F6DAD] -translate-y-1' : 'text-slate-300'}`}><item.icon className={`w-6 h-6 ${activeTab === item.id ? 'fill-current' : ''}`} /><span className="text-[10px] font-bold">{item.label}</span></button>
))}
</nav>
{modalBadge && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-6 animate-fade-in">
<div className="bg-white rounded-3xl p-8 w-full max-w-sm text-center relative shadow-2xl flex flex-col items-center">
<button onClick={() => setModalBadge(null)} className="absolute top-4 right-4 text-slate-400"><X/></button>
<div className="text-7xl mb-4 animate-bounce mt-4">{modalBadge.icon}</div>
<h3 className="text-2xl font-bold text-[#4F6DAD] mb-1">{modalBadge.name}</h3>
<div className="bg-slate-50 px-4 py-1.5 rounded-full mb-6 border border-slate-100 shadow-sm mt-2"><span className="text-xs text-slate-500 font-bold flex items-center gap-1"><Clock className="w-3 h-3"/> Dicapai: {modalBadge.date}</span></div>
<p className="text-slate-600 text-sm mb-8 px-4 text-center leading-relaxed">{modalBadge.desc}</p>
<button onClick={() => setModalBadge(null)} className="w-full bg-[#4F6DAD] text-white py-3 rounded-xl font-bold flex items-center justify-center gap-2"><ArrowRight className="w-4 h-4"/> Tutup</button>
</div>
</div>
)}
{showHelpModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-6 animate-fade-in">
<div className="bg-white rounded-3xl p-6 w-full max-w-sm shadow-2xl relative">
<button onClick={() => setShowHelpModal(false)} className="absolute top-4 right-4 text-slate-400 hover:text-slate-600"><X className="w-5 h-5"/></button>
<div className="flex items-center gap-2 mb-6 text-red-600">
<AlertTriangle className="w-6 h-6"/>
<h3 className="text-xl font-bold">Bantuan Darurat</h3>
</div>
<div className="space-y-4">
<a href="tel:119" className="flex items-center gap-4 p-4 bg-red-50 text-red-700 rounded-2xl border border-red-100 hover:bg-red-100 transition-colors">
<div className="bg-red-200 p-3 rounded-full"><Phone className="w-6 h-6"/></div>
<div>
<div className="font-bold text-lg">Panggil Ambulans</div>
<div className="text-xs opacity-70">Nomor Darurat: 119</div>
</div>
</a>
<a href="https://www.intothelightid.org/tentang-bunuh-diri/layanan-konseling-psikolog-psikiater/" target="_blank" rel="noopener noreferrer" className="flex items-center gap-4 p-4 bg-blue-50 text-blue-700 rounded-2xl border border-blue-100 hover:bg-blue-100 transition-colors">
<div className="bg-blue-200 p-3 rounded-full"><Globe className="w-6 h-6"/></div>
<div>
<div className="font-bold text-lg">Layanan Profesional</div>
<div className="text-xs opacity-70">Konseling & Psikolog</div>
</div>
</a>
</div>
<p className="text-center text-xs text-slate-400 mt-6 px-4">
Jangan ragu untuk meminta bantuan. Anda tidak sendirian.
</p>
</div>
</div>
)}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<PsychologyJournalApp />);
</script>
</body>
</html>

View File

@ -1,79 +0,0 @@
package com.example.ppb_kelompok2
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
// --- 1. Sentiment Analysis Models ---
data class SentimentRequest(val inputs: String)
typealias SentimentResponse = List<List<EmotionScore>>
data class EmotionScore(
val label: String,
val score: Float
)
// --- 2. Zero-Shot Classification Models ---
data class ZeroShotRequest(
val inputs: String,
val parameters: ZeroShotParameters
)
data class ZeroShotParameters(
val candidate_labels: List<String>,
val multi_label: Boolean = true // True = satu teks bisa masuk ke banyak kategori
)
data class ZeroShotResponse(
val sequence: String,
val labels: List<String>,
val scores: List<Float>
)
// --- 3. Interface API Definition ---
interface HuggingFaceApiService {
// Model 1: Emosi (Inggris) - Cepat & Ringan
@POST("models/j-hartmann/emotion-english-distilroberta-base")
suspend fun analyzeEmotion(
@Header("Authorization") authHeader: String,
@Body request: SentimentRequest
): SentimentResponse
// Model 2: Zero-Shot Classification (Multilingual) - Lebih Berat tapi Detail
// Menggunakan joeddav/xlm-roberta-large-xnli untuk support Bahasa Indonesia
@POST("models/joeddav/xlm-roberta-large-xnli")
suspend fun analyzeZeroShot(
@Header("Authorization") authHeader: String,
@Body request: ZeroShotRequest
): ZeroShotResponse
}
// --- 4. Singleton Instance (with Logging) ---
object RetrofitClient {
private const val BASE_URL = "https://api-inference.huggingface.co/"
// Membuat Interceptor untuk logging. Level BODY akan menampilkan semua detail request/response.
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// Menambahkan interceptor ke OkHttpClient
private val httpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
val apiService: HuggingFaceApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(httpClient) // Menggunakan client custom yang sudah ada logger-nya
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(HuggingFaceApiService::class.java)
}
}

View File

@ -1,444 +1,61 @@
package com.example.ppb_kelompok2 package com.example.ppb_kelompok2
import android.Manifest import android.annotation.SuppressLint
import android.app.AlarmManager
import android.app.PendingIntent
import android.app.TimePickerDialog
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.view.ViewGroup
import android.widget.Toast import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.animation.animateColorAsState import androidx.compose.material3.Surface
import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.*
import coil.compose.AsyncImage
import com.example.ppb_kelompok2.ui.theme.PPB_Kelompok2Theme
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.firebase.auth.ktx.auth
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.ktx.firestore
import com.google.firebase.ktx.Firebase
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
import kotlin.random.Random
// --- Constants ---
val HF_API_TOKEN = "Bearer ${BuildConfig.HF_API_KEY}"
// --- Data Models ---
data class JournalEntry(
val id: String = "",
val userId: String = "",
val content: String = "",
val type: String = "journal",
val sentiment: String = "",
val confidence: Float = 0f,
val indicators: Map<String, Float> = emptyMap(),
val mentalScore: Int = 0,
val timestamp: com.google.firebase.Timestamp? = null,
val dateString: String = ""
)
data class Badge(val title: String, val description: String, val icon: ImageVector, val isUnlocked: Boolean)
// --- Main Activity ---
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PPB_Kelompok2Theme { Surface(modifier = Modifier.fillMaxSize()) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { WebViewContainer()
AppNavigationGraph() }
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewContainer() {
AndroidView(
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Konfigurasi WebView agar React berjalan lancar
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
loadWithOverviewMode = true
useWideViewPort = true
cacheMode = WebSettings.LOAD_DEFAULT
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
} }
webViewClient = WebViewClient()
// Memuat file index.html dari folder assets
loadUrl("file:///android_asset/index.html")
} }
} },
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { modifier = Modifier.fillMaxSize()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101)
}
}
}
}
@Composable
fun AppNavigationGraph() {
val navController = rememberNavController()
val auth = Firebase.auth
val startDestination = if (auth.currentUser != null) "main" else "login"
NavHost(navController = navController, startDestination = startDestination) {
composable("login") { LoginScreen(navController = navController) }
composable("main") { MainAppScreen() }
}
}
@Composable
fun LoginScreen(navController: NavController) {
val context = LocalContext.current
var isLoading by remember { mutableStateOf(false) }
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken("532164852718-k3op2ns8b5v42k2e5qj8j1d7g2g1m3g9.apps.googleusercontent.com")
.requestEmail()
.build()
val googleSignInClient = GoogleSignIn.getClient(context, gso)
val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == ComponentActivity.RESULT_OK) {
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try {
val account = task.getResult(ApiException::class.java)!!
val credential = com.google.firebase.auth.GoogleAuthProvider.getCredential(account.idToken, null)
isLoading = true
Firebase.auth.signInWithCredential(credential).addOnCompleteListener { authTask ->
isLoading = false
if (authTask.isSuccessful) {
navController.navigate("main") { popUpTo("login") { inclusive = true } }
} else {
Toast.makeText(context, "Login Gagal: ${authTask.exception?.message}", Toast.LENGTH_LONG).show()
}
}
} catch (e: ApiException) {
isLoading = false
Log.w("LoginScreen", "Google sign in failed", e)
Toast.makeText(context, "Google Sign In Gagal. Cek SHA-1.", Toast.LENGTH_LONG).show()
}
} else {
isLoading = false
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text("MindTrack AI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text("Lacak kesehatan mental, latih otak, dan temukan ketenangan.", style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 32.dp))
Spacer(modifier = Modifier.height(32.dp))
if (isLoading) {
CircularProgressIndicator()
} else {
Button(onClick = { isLoading = true; launcher.launch(googleSignInClient.signInIntent) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp)) {
Icon(Icons.Default.AccountCircle, contentDescription = "Google Icon")
Spacer(modifier = Modifier.width(8.dp))
Text("Masuk dengan Google")
}
}
}
}
sealed class Screen(val route: String, val label: String, val icon: ImageVector) {
object Journal : Screen("journal", "Jurnal", Icons.Default.Book)
object Assessment : Screen("assessment", "Penilaian", Icons.Default.Checklist)
object CognitiveTest : Screen("cognitive_test", "Tes Kognitif", Icons.Default.SportsEsports)
object Profile : Screen("profile", "Profil", Icons.Default.Person)
}
val bottomNavItems = listOf(Screen.Journal, Screen.Assessment, Screen.CognitiveTest, Screen.Profile)
@Composable
fun MainAppScreen() {
val navController = rememberNavController()
Scaffold(bottomBar = { AppBottomNavigation(navController = navController) }) { innerPadding ->
NavHost(navController = navController, startDestination = Screen.Journal.route, modifier = Modifier.padding(innerPadding)) {
composable(Screen.Journal.route) { JournalScreen() }
composable(Screen.Assessment.route) { AssessmentScreen() }
composable(Screen.CognitiveTest.route) { CognitiveTestScreen(navController) }
composable(Screen.Profile.route) { ProfileScreen(navController = navController) }
composable("memory_test") { MemoryTestScreen(navController) }
composable("focus_test") { FocusTestScreen(navController) }
composable("reaction_test") { ReactionSpeedTestScreen(navController) }
composable("logical_test") { LogicalTestScreen(navController) }
composable("journal_history") { JournalHistoryScreen(navController = navController) }
}
}
}
@Composable
fun AppBottomNavigation(navController: NavHostController) {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
bottomNavItems.forEach { screen ->
NavigationBarItem(
icon = { Icon(screen.icon, contentDescription = screen.label) },
label = { Text(screen.label) },
selected = currentRoute == screen.route,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
fun calculateDailyStreak(journals: List<JournalEntry>): Int {
if (journals.isEmpty()) return 0
val entryDates = journals.map { it.timestamp?.toDate() ?: Date(0) }.map { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(it) }.distinct().sortedDescending()
var streak = 0
val calendar = Calendar.getInstance()
val todayStr = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time)
if (todayStr in entryDates) {
streak++
calendar.add(Calendar.DATE, -1)
} else {
calendar.add(Calendar.DATE, -1)
val yesterdayStr = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time)
if(yesterdayStr !in entryDates) return 0
}
for (i in 1 until entryDates.size) {
val expectedDateStr = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time)
if (entryDates.getOrNull(i) == expectedDateStr) {
streak++
calendar.add(Calendar.DATE, -1)
} else {
break
}
}
return streak
}
fun getBadges(journals: List<JournalEntry>, streak: Int): List<Badge> {
val totalEntries = journals.size
val reflectionEntries = journals.count { it.type == "reflection" }
val highStressEntries = journals.count { it.mentalScore > 60 }
return listOf(
Badge("Jurnalis Pertama", "Menulis jurnal pertamamu", Icons.Default.Edit, totalEntries >= 1),
Badge("Seminggu Penuh", "Streak jurnaling 7 hari", Icons.Default.CalendarToday, streak >= 7),
Badge("Reflektor", "Selesaikan 5 refleksi", Icons.Default.SelfImprovement, reflectionEntries >= 5),
Badge("Sadar Diri", "Menganalisis 10 jurnal", Icons.Default.Insights, totalEntries >= 10),
Badge("Pejuang Tangguh", "Mengatasi 3 hari skor tinggi", Icons.Default.Shield, highStressEntries >= 3)
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(navController: NavController) {
val auth = Firebase.auth
val user = auth.currentUser
val db = Firebase.firestore
var journalList by remember { mutableStateOf<List<JournalEntry>>(emptyList()) }
var dailyStreak by remember { mutableIntStateOf(0) }
var badges by remember { mutableStateOf<List<Badge>>(emptyList()) }
LaunchedEffect(user) {
if (user != null) {
db.collection("journals").whereEqualTo("userId", user.uid).orderBy("timestamp").get().addOnSuccessListener { result ->
val journals = result.toObjects(JournalEntry::class.java)
journalList = journals
dailyStreak = calculateDailyStreak(journals)
badges = getBadges(journals, dailyStreak)
}
}
}
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
item { if (user != null) { Row(verticalAlignment = Alignment.CenterVertically) { AsyncImage(model = user.photoUrl, contentDescription = "Profile Picture", modifier = Modifier.size(64.dp).clip(CircleShape)); Spacer(modifier = Modifier.width(16.dp)); Column { Text(user.displayName ?: "Pengguna", style = MaterialTheme.typography.headlineSmall); Text(user.email ?: "", style = MaterialTheme.typography.bodyMedium) } } } }
item { Card(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.LocalFireDepartment, contentDescription = "Streak", tint = Color(0xFFFFA500), modifier = Modifier.size(40.dp)); Spacer(modifier = Modifier.width(16.dp)); Column { Text("$dailyStreak Hari", style = MaterialTheme.typography.headlineMedium); Text("Streak Jurnaling Harian", style = MaterialTheme.typography.bodySmall) } } } }
item {
Text("Lencana Pencapaian", style = MaterialTheme.typography.titleMedium, modifier = Modifier.fillMaxWidth())
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 100.dp), contentPadding = PaddingValues(top = 8.dp), modifier = Modifier.height(120.dp).fillMaxWidth()) {
items(badges) { BadgeItem(badge = it) }
}
}
item {
val currentMonth = Calendar.getInstance().get(Calendar.MONTH)
val currentDay = Calendar.getInstance().get(Calendar.DAY_OF_MONTH)
val isReportAvailable = currentMonth == Calendar.DECEMBER && currentDay >= 15
Card(onClick = { /* Navigasi ke raport */ }, enabled = isReportAvailable, modifier = Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)){ Text("Raport Tahunan", fontWeight = FontWeight.Bold); Text(if(isReportAvailable) "Raport tahun ini sudah tersedia!" else "Tersedia setiap 15 Desember.", style = MaterialTheme.typography.bodySmall) } }
}
item { OutlinedButton(onClick = { navController.navigate("journal_history") }, modifier = Modifier.fillMaxWidth()) { Text("Lihat Riwayat Jurnal Lengkap") } }
}
}
@Composable
fun BadgeItem(badge: Badge) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(8.dp).alpha(if (badge.isUnlocked) 1f else 0.4f)) {
Icon(badge.icon, contentDescription = badge.title, modifier = Modifier.size(48.dp), tint = if (badge.isUnlocked) MaterialTheme.colorScheme.primary else Color.Gray)
Spacer(modifier = Modifier.height(4.dp))
Text(badge.title, fontSize = 12.sp, textAlign = TextAlign.Center, lineHeight = 14.sp)
}
}
fun scheduleReminder(context: Context, hour: Int, minute: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, MyReminderReceiver::class.java).apply { putExtra("title", "Waktunya Jurnaling!"); putExtra("message", "Luangkan waktu sejenak untuk refleksi diri.") }
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val calendar = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, hour); set(Calendar.MINUTE, minute); set(Calendar.SECOND, 0); if (before(Calendar.getInstance())) add(Calendar.DATE, 1) }
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if(alarmManager.canScheduleExactAlarms()){
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
}
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
}
Toast.makeText(context, "Pengingat diatur!", Toast.LENGTH_SHORT).show()
} catch (e: SecurityException) {
Toast.makeText(context, "Izin alarm tidak diberikan", Toast.LENGTH_SHORT).show()
}
}
fun calculateMentalHealthScore(sentimentLabel: String, sentimentScore: Float, indicators: Map<String, Float>): Int {
var baseScore = 30.0
when (sentimentLabel) { "sadness", "fear", "anger" -> baseScore += (sentimentScore * 10); "joy" -> baseScore -= (sentimentScore * 10) }
val criticalIndicators = listOf("pikiran bunuh diri", "rencana bunuh diri", "keinginan untuk mati", "pikiran tentang kematian")
val heavyIndicators = listOf("suasana hati sedih", "perasaan tidak berharga", "pesimis", "menarik diri sosial", "perasaan bersalah", "kehilangan minat", "menyalahkan diri sendiri")
val mediumIndicators = listOf("sulit berkonsentrasi", "sulit mengambil keputusan", "gangguan tidur", "kehilangan energi", "mudah marah", "penurunan aktivitas", "perubahan nafsu makan", "perubahan berat badan")
indicators.forEach { (label, score) -> if (score > 0.4) { when (label) { in criticalIndicators -> baseScore += 50.0; in heavyIndicators -> baseScore += 15.0; in mediumIndicators -> baseScore += 8.0 } } }
return baseScore.coerceIn(0.0, 100.0).toInt()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalScreen() {
var selectedTab by remember { mutableIntStateOf(0) }; var journalText by remember { mutableStateOf("") }; var reflectionAnswers by remember { mutableStateOf(List(3) { "" }) }; var isSaving by remember { mutableStateOf(false) }; var detectedEmotion by remember { mutableStateOf<String?>(null) }; var detectedIssues by remember { mutableStateOf<String?>(null) }; var calculatedScoreFeedback by remember { mutableIntStateOf(-1) }; var isCriticalRisk by remember { mutableStateOf(false) }; val context = LocalContext.current; val auth = Firebase.auth; val db = Firebase.firestore; val scope = rememberCoroutineScope(); val reflectionQuestions = listOf("Satu hal kecil yang membuatmu tersenyum hari ini?", "Tantangan terbesar hari ini & solusinya?", "Satu hal yang ingin kamu perbaiki besok?"); val calendar = Calendar.getInstance(); val timePickerDialog = TimePickerDialog(context, { _, hour, minute -> scheduleReminder(context, hour, minute) }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true); val depressionIndicators = listOf("suasana hati sedih", "kehilangan minat", "mudah marah", "perasaan bersalah", "perasaan tidak berharga", "sulit berkonsentrasi", "sulit mengambil keputusan", "pesimis", "menyalahkan diri sendiri", "kehilangan energi", "penurunan aktivitas", "menarik diri sosial", "gangguan tidur", "perubahan nafsu makan", "perubahan berat badan", "pikiran tentang kematian", "keinginan untuk mati", "pikiran bunuh diri", "rencana bunuh diri")
Scaffold(topBar = { TopAppBar(title = { Text("Jurnal & Refleksi") }, actions = { IconButton(onClick = { timePickerDialog.show() }) { Icon(Icons.Default.Alarm, contentDescription = "Set Reminder") } }) }) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
TabRow(selectedTabIndex = selectedTab) { Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Jurnal Bebas") }); Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Refleksi") }) }
Spacer(modifier = Modifier.height(16.dp))
if (selectedTab == 0) {
Card(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), colors = if (isCriticalRisk) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) else CardDefaults.cardColors()) { Column(modifier = Modifier.padding(16.dp)) { Text("Analisis AI", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold); if (isCriticalRisk) { Text("⚠️ PERINGATAN RISIKO", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Bold); Text("Terdeteksi sinyal bahaya. Anda tidak sendiri. Segera hubungi bantuan profesional.", style = MaterialTheme.typography.bodySmall); Spacer(Modifier.height(8.dp)) }; if (detectedEmotion != null) Text("Emosi: $detectedEmotion", color = MaterialTheme.colorScheme.primary); if (detectedIssues != null) Text("Indikator: $detectedIssues", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary); if (calculatedScoreFeedback != -1) Text("Skor Depresi: $calculatedScoreFeedback/100", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold); if (detectedEmotion == null && detectedIssues == null) Text("Ceritakan harimu untuk analisis mendalam.", style = MaterialTheme.typography.bodySmall, color = Color.Gray) } }
OutlinedTextField(value = journalText, onValueChange = { journalText = it }, label = { Text("Tuliskan perasaanmu di sini...") }, modifier = Modifier.fillMaxWidth().weight(1f))
} else {
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { items(reflectionQuestions.size) { index -> Text(reflectionQuestions[index], fontWeight = FontWeight.SemiBold); OutlinedTextField(value = reflectionAnswers[index], onValueChange = { newValue -> val newList = reflectionAnswers.toMutableList(); newList[index] = newValue; reflectionAnswers = newList }, modifier = Modifier.fillMaxWidth(), placeholder = { Text("Jawabanmu...") }) } }
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { val user = auth.currentUser; if (user == null) { Toast.makeText(context, "Login dulu", Toast.LENGTH_SHORT).show(); return@Button }; val contentToSave = if (selectedTab == 0) journalText else reflectionAnswers.joinToString("\n\n") { it }; if (contentToSave.isBlank()) { Toast.makeText(context, "Isi tidak boleh kosong", Toast.LENGTH_SHORT).show(); return@Button }; isSaving = true; detectedEmotion = "Menganalisis..."; detectedIssues = ""; calculatedScoreFeedback = -1; isCriticalRisk = false
scope.launch { var sentimentLabel = "Netral"; var sentimentScore = 0.0f; val detectedIndicators = mutableMapOf<String, Float>(); if (contentToSave.length > 10) { val emotionJob = async { try { RetrofitClient.apiService.analyzeEmotion(HF_API_TOKEN, SentimentRequest(inputs = contentToSave)) } catch (e: Exception) { null } }; val zeroShotJob = async { try { RetrofitClient.apiService.analyzeZeroShot(HF_API_TOKEN, ZeroShotRequest(inputs = contentToSave, parameters = ZeroShotParameters(candidate_labels = depressionIndicators))) } catch (e: Exception) { null } }; val emotionResponse = emotionJob.await(); val zeroShotResponse = zeroShotJob.await(); if (emotionResponse != null && emotionResponse.isNotEmpty() && emotionResponse[0].isNotEmpty()) { val topEmotion = emotionResponse[0].maxByOrNull { it.score }; if (topEmotion != null) { sentimentLabel = topEmotion.label; sentimentScore = topEmotion.score; detectedEmotion = "${sentimentLabel.replaceFirstChar { it.uppercase() }} (${(sentimentScore * 100).toInt()}%)" } }; if (zeroShotResponse != null) { val labels = zeroShotResponse.labels; val scores = zeroShotResponse.scores; val significantIssues = mutableListOf<String>(); val criticalList = listOf("pikiran bunuh diri", "rencana bunuh diri", "keinginan untuk mati"); for (i in labels.indices) { if (scores[i] > 0.4) { detectedIndicators[labels[i]] = scores[i]; significantIssues.add(labels[i]); if (labels[i] in criticalList) { isCriticalRisk = true } } }; detectedIssues = if (significantIssues.isNotEmpty()) significantIssues.take(3).joinToString(", ") else "Tidak ada indikator signifikan." } }
val mentalScore = calculateMentalHealthScore(sentimentLabel, sentimentScore, detectedIndicators); calculatedScoreFeedback = mentalScore
val entry = hashMapOf("userId" to user.uid, "content" to contentToSave, "type" to if (selectedTab == 0) "journal" else "reflection", "sentiment" to sentimentLabel, "confidence" to sentimentScore, "indicators" to detectedIndicators, "mentalScore" to mentalScore, "timestamp" to FieldValue.serverTimestamp(), "dateString" to SimpleDateFormat("dd MMM yyyy, HH:mm", Locale.getDefault()).format(Date())); db.collection("journals").add(entry).addOnSuccessListener { Toast.makeText(context, "Tersimpan! Skor: $mentalScore", Toast.LENGTH_SHORT).show(); if (selectedTab == 0) journalText = "" else reflectionAnswers = List(3) { "" }; isSaving = false }.addOnFailureListener { Toast.makeText(context, "Gagal menyimpan", Toast.LENGTH_SHORT).show(); isSaving = false } } },
modifier = Modifier.fillMaxWidth(), enabled = !isSaving, colors = if (isCriticalRisk) ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) else ButtonDefaults.buttonColors()) { if (isSaving) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White); Spacer(modifier = Modifier.width(8.dp)); Text("Menganalisis...") } } else { Text("Simpan Jurnal") } }
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @Composable fun AssessmentScreen() { val indicators = remember { listOf("Mood Sedih", "Rasa Bersalah", "Menarik Diri", "Sulit Konsentrasi", "Lelah", "Pikiran Bunuh Diri") }; val sliderValues = remember { mutableStateMapOf<String, Float>().apply { indicators.forEach { put(it, 0f) } } }; val totalScore = sliderValues.values.sum().toInt(); val assessmentLevel = when (totalScore) { in 0..4 -> "Normal"; in 5..9 -> "Ringan"; in 10..14 -> "Sedang"; else -> "Berat" }; Scaffold(topBar = { TopAppBar(title = { Text("Penilaian Harian") }) }) { innerPadding -> LazyColumn(modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { item { Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { Column(modifier = Modifier.padding(16.dp)) { Text("Ringkasan Skor", style = MaterialTheme.typography.titleMedium); Text("Total Skor: $totalScore", style = MaterialTheme.typography.headlineMedium); Text("Tingkat: $assessmentLevel", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) } } }; items(indicators) { indicator -> IndicatorItem(indicatorName = indicator, value = sliderValues[indicator] ?: 0f) { sliderValues[indicator] = it.roundToInt().toFloat() } }; item { Button(onClick = { /* Save logic */ }, modifier = Modifier.fillMaxWidth()) { Text("Selesai") } } } } }
@Composable fun IndicatorItem(indicatorName: String, value: Float, onValueChange: (Float) -> Unit) { val description = when (value.toInt()) { 0 -> "Tidak sama sekali"; 1 -> "Beberapa hari"; 2 -> "Separuh hari"; 3 -> "Hampir setiap hari"; else -> "" }; Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(indicatorName, style = MaterialTheme.typography.titleMedium); Text(text = value.toInt().toString(), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) }; Slider(value = value, onValueChange = onValueChange, valueRange = 0f..3f, steps = 2); Text(text = description, style = MaterialTheme.typography.bodySmall, modifier = Modifier.align(Alignment.CenterHorizontally)) } } }
@OptIn(ExperimentalMaterial3Api::class) @Composable fun CognitiveTestScreen(navController: NavController) { Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("Pilih Tes Kognitif", style = MaterialTheme.typography.headlineSmall); TestCard("Tes Memori", "Uji memori jangka pendek", Icons.Default.Memory, "memory_test", navController); TestCard("Tes Fokus", "Uji fokus & atensi", Icons.Default.CenterFocusStrong, "focus_test", navController); TestCard("Tes Kecepatan Reaksi", "Uji refleks visual", Icons.Default.Speed, "reaction_test", navController); TestCard("Tes Logika", "Uji pola pikir & logika", Icons.Default.Psychology, "logical_test", navController) } }
@OptIn(ExperimentalMaterial3Api::class) @Composable fun TestCard(title: String, description: String, icon: ImageVector, route: String, navController: NavController) { Card(onClick = { navController.navigate(route) }, modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Icon(icon, null, modifier = Modifier.size(40.dp)); Spacer(Modifier.width(16.dp)); Column { Text(title, style = MaterialTheme.typography.titleMedium); Text(description, style = MaterialTheme.typography.bodySmall, color = Color.Gray) } } } }
data class LogicalQuestion(val question: String, val options: List<String>, val correctAnswer: Int)
@OptIn(ExperimentalMaterial3Api::class) @Composable fun LogicalTestScreen(navController: NavController) { val questions = remember { listOf(LogicalQuestion("Pola: 2, 4, 8, 16, ?", listOf("24", "32", "30", "20"), 1), LogicalQuestion("Paus di air. Maka...", listOf("Paus ikan", "Paus bukan ikan", "Tak dapat disimpulkan"), 2)).shuffled() }; var currentQuestionIndex by remember { mutableIntStateOf(0) }; var score by remember { mutableIntStateOf(0) }; var isFinished by remember { mutableStateOf(false) }; Scaffold(topBar = { TopAppBar(title = { Text("Tes Logika") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.Default.ArrowBack, null) } }) }) { padding -> Column(modifier = Modifier.padding(padding).padding(16.dp).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { if (!isFinished) { val q = questions[currentQuestionIndex]; Text("Pertanyaan ${currentQuestionIndex + 1} / ${questions.size}"); Spacer(Modifier.height(16.dp)); Card(modifier = Modifier.fillMaxWidth()) { Text(q.question, modifier = Modifier.padding(16.dp)) }; Spacer(Modifier.height(24.dp)); q.options.forEachIndexed { index, option -> Button(onClick = { if (index == q.correctAnswer) score++; if (currentQuestionIndex < questions.size - 1) currentQuestionIndex++ else isFinished = true }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(option) } } } else { Text("Tes Selesai! Skor: $score/${questions.size}", style = MaterialTheme.typography.headlineMedium); Button(onClick = { navController.popBackStack() }) { Text("Kembali") } } } } }
data class MemoryCard(val id: Int, val icon: ImageVector, var isFaceUp: Boolean = false, var isMatched: Boolean = false)
enum class MemoryGameState { READY, PLAYING, FINISHED }
@OptIn(ExperimentalMaterial3Api::class) @Composable fun MemoryTestScreen(navController: NavController) { val icons = listOf(Icons.Default.Favorite, Icons.Default.Star, Icons.Default.ThumbUp, Icons.Default.Spa, Icons.Default.Cloud, Icons.Default.Anchor); var cards by remember { mutableStateOf((icons + icons).mapIndexed { i, icon -> MemoryCard(i, icon) }.shuffled()) }; var selectedCards by remember { mutableStateOf(listOf<MemoryCard>()) }; var moves by remember { mutableIntStateOf(0) }; var gameState by remember { mutableStateOf(MemoryGameState.READY) }; LaunchedEffect(selectedCards) { if (selectedCards.size == 2) { val (c1, c2) = selectedCards; if (c1.icon == c2.icon) { cards = cards.map { if (it.id == c1.id || it.id == c2.id) it.copy(isMatched = true) else it } } else { delay(1000); cards = cards.map { if (it.id == c1.id || it.id == c2.id) it.copy(isFaceUp = false) else it } }; selectedCards = emptyList() } }; Scaffold(topBar = { TopAppBar(title = { Text("Tes Memori") }, navigationIcon = { IconButton(onClick = {navController.popBackStack()}) { Icon(Icons.Default.ArrowBack,null)}})}) { p -> Column(Modifier.padding(p).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { if (gameState == MemoryGameState.READY) { Button(onClick = { gameState = MemoryGameState.PLAYING }) { Text("Mulai") } } else { Text("Gerakan: $moves"); LazyVerticalGrid(GridCells.Fixed(3)) { items(cards) { card -> MemoryCardView(card) { if (!card.isFaceUp && !card.isMatched && selectedCards.size < 2) { cards = cards.map { if (it.id == card.id) it.copy(isFaceUp = true) else it }; selectedCards = selectedCards + card; if (selectedCards.size == 1) moves++ } } } }; if (cards.all { it.isMatched }) { Text("Selesai! Skor: $moves"); Button(onClick = { cards = (icons + icons).mapIndexed{i,c->MemoryCard(i,c)}.shuffled(); moves=0; gameState=MemoryGameState.PLAYING }) { Text("Main Lagi") } } } } } }
@OptIn(ExperimentalMaterial3Api::class) @Composable fun MemoryCardView(card: MemoryCard, onClick: () -> Unit) { Card(onClick = onClick, modifier = Modifier.padding(4.dp).aspectRatio(1f)) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if(card.isFaceUp||card.isMatched) Icon(card.icon,null) } } }
enum class FocusGameState { READY, PLAYING, FINISHED }
data class FocusItem(val icon: ImageVector, val color: Color, val rotation: Float, val isDistractor: Boolean)
@OptIn(ExperimentalMaterial3Api::class) @Composable fun FocusTestScreen(navController: NavController) { var score by remember { mutableIntStateOf(0) }; var highScore by remember { mutableIntStateOf(0) }; val normalColor = MaterialTheme.colorScheme.onSurface; var gridItems by remember { mutableStateOf(generateFocusGrid(normalColor)) }; var gameState by remember { mutableStateOf(FocusGameState.READY) }; var selectedDuration by remember { mutableIntStateOf(15) }; var timeLeft by remember { mutableIntStateOf(selectedDuration) }; LaunchedEffect(gameState) { if (gameState == FocusGameState.PLAYING) { timeLeft = selectedDuration; while (timeLeft > 0) { delay(1000); timeLeft-- }; if (timeLeft == 0) gameState = FocusGameState.FINISHED } else if (gameState == FocusGameState.FINISHED) { if (score > highScore) { highScore = score } } }; fun newLevel() { gridItems = generateFocusGrid(normalColor) }; fun restartGame() { score = 0; gameState = FocusGameState.READY; newLevel() }; Scaffold(topBar = { TopAppBar(title = { Text("Tes Fokus") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.Default.ArrowBack, null) } }) }) { innerPadding -> Column(modifier = Modifier.fillMaxSize().padding(innerPadding).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { when (gameState) { FocusGameState.READY -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Tes Fokus"); Button(onClick = { gameState = FocusGameState.PLAYING }) { Text("Mulai") } } }; FocusGameState.PLAYING -> { Row { Text("Skor: $score"); Text("Waktu: $timeLeft") }; LazyVerticalGrid(columns = GridCells.Fixed(5)) { items(gridItems.indices.toList()) { index -> val item = gridItems[index]; Icon(imageVector = item.icon, null, modifier = Modifier.clickable { if (item.isDistractor) { score++; newLevel() } else { if (score > 0) score-- } }) } } }; FocusGameState.FINISHED -> { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Waktu Habis!"); Text("Skor Akhir: $score"); Button(onClick = { restartGame() }) { Text("Coba Lagi") } } } } } } }
private fun generateFocusGrid(normalColor: Color): List<FocusItem> { val gridSize = 25; val normalIcon = Icons.Default.Circle; val distractorIndex = Random.nextInt(gridSize); val distractorType = Random.nextInt(3); val distractor: FocusItem; val items = MutableList(gridSize) { FocusItem(normalIcon, normalColor, 0f, false) }; when (distractorType) { 0 -> { distractor = FocusItem(Icons.Default.Star, normalColor, 0f, true) }; 1 -> { distractor = FocusItem(normalIcon, Color.Red, 0f, true) }; else -> { distractor = FocusItem(Icons.Default.Navigation, normalColor, 90f, true); items.replaceAll { it.copy(icon = Icons.Default.Navigation) } } }; items[distractorIndex] = distractor; return items }
enum class ReactionGameState { READY, WAITING, ACTION, FINISHED }
@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReactionSpeedTestScreen(navController: NavController) { var state by remember { mutableStateOf(ReactionGameState.READY) }; var startTime by remember { mutableLongStateOf(0L) }; var reactionTime by remember { mutableLongStateOf(0L) }; var bestTime by remember { mutableStateOf<Long?>(null) }; val backgroundColor by animateColorAsState(targetValue = when (state) { ReactionGameState.WAITING -> Color.Red; ReactionGameState.ACTION -> Color.Green; else -> MaterialTheme.colorScheme.surface }, label=""); val onScreenClick = { when (state) { ReactionGameState.WAITING -> { reactionTime = -1; state = ReactionGameState.FINISHED }; ReactionGameState.ACTION -> { val newTime = System.currentTimeMillis() - startTime; reactionTime = newTime; if (bestTime == null || newTime < bestTime!!) { bestTime = newTime }; state = ReactionGameState.FINISHED }; else -> {} } }; LaunchedEffect(state) { if (state == ReactionGameState.WAITING) { delay(Random.nextLong(1500, 5500)); if (state == ReactionGameState.WAITING) { startTime = System.currentTimeMillis(); state = ReactionGameState.ACTION } } }; Scaffold(topBar = { TopAppBar(title = { Text("Tes Kecepatan Reaksi") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.Default.ArrowBack, null) } }) }) { innerPadding -> Box(modifier = Modifier.fillMaxSize().padding(innerPadding).background(backgroundColor).clickable(enabled = state == ReactionGameState.WAITING || state == ReactionGameState.ACTION, onClick = onScreenClick), contentAlignment = Alignment.Center) { when (state) { ReactionGameState.READY -> { Column { Text("Tes Kecepatan Reaksi"); Button(onClick = { state = ReactionGameState.WAITING }) { Text("Mulai") } } }; ReactionGameState.WAITING -> { Text("Tunggu sampai hijau...") }; ReactionGameState.ACTION -> { Text("Tekan Sekarang!") }; ReactionGameState.FINISHED -> { Column { val resultText = if (reactionTime == -1L) "Terlalu Cepat!" else "${reactionTime} ms"; Text(resultText); Button(onClick = { state = ReactionGameState.READY }) { Text("Coba Lagi") } } } } } } }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JournalHistoryScreen(navController: NavController) {
val auth = Firebase.auth; val db = Firebase.firestore; var journalList by remember { mutableStateOf<List<JournalEntry>>(emptyList()) }
LaunchedEffect(Unit) { auth.currentUser?.let { user -> db.collection("journals").whereEqualTo("userId", user.uid).orderBy("timestamp", com.google.firebase.firestore.Query.Direction.DESCENDING).get().addOnSuccessListener { res -> journalList = res.map { doc -> val indicatorsMap = doc.get("indicators") as? Map<String, Float> ?: emptyMap(); val score = (doc.get("mentalScore") as? Long)?.toInt() ?: 0; JournalEntry(id=doc.id, content=doc.getString("content")?:"", type=doc.getString("type")?:"journal", sentiment=doc.getString("sentiment")?:"", indicators = indicatorsMap, mentalScore = score, dateString=doc.getString("dateString")?:"") } } } }
Scaffold(topBar = { TopAppBar(title = { Text("Riwayat Jurnal") }, navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon(Icons.Default.ArrowBack, null) } }) }) {
LazyColumn(modifier = Modifier.padding(it).fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
item { Text("Semua Entri Jurnal Anda", style = MaterialTheme.typography.headlineSmall) }
if (journalList.isNotEmpty()) { item { TrendGraph(journals = journalList) }; item { Spacer(modifier = Modifier.height(8.dp)) } }
items(journalList) { journal ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(if(journal.type == "reflection") "Refleksi" else "Jurnal", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
Text("Skor: ${journal.mentalScore}/100", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = if(journal.mentalScore > 60) Color.Red else if(journal.mentalScore > 40) Color(0xFFFFA500) else Color.Green)
}
Spacer(Modifier.height(4.dp))
Text(journal.dateString, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
if (journal.sentiment.isNotEmpty()) Text("Mood: ${journal.sentiment}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary)
Spacer(Modifier.height(8.dp))
Text(journal.content, maxLines = 4)
if (journal.indicators.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
Text("Indikator Terdeteksi:", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelSmall)
Row(modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
journal.indicators.entries.sortedByDescending { it.value }.take(3).forEach { (key, _) -> SuggestionChip(onClick = {}, label = { Text(key) }) }
}
}
}
}
}
}
}
}
//eeeuiuifs
@Composable
fun TrendGraph(journals: List<JournalEntry>) {
if (journals.size < 2) {
Box(modifier = Modifier.fillMaxWidth().height(220.dp), contentAlignment = Alignment.Center) {
Text("Grafik akan muncul setelah ada minimal 2 jurnal.", textAlign = TextAlign.Center)
}
} else {
val dataPoints = journals.sortedBy { it.timestamp?.seconds ?: 0 }.takeLast(7).map { it.mentalScore }
Card(modifier = Modifier.fillMaxWidth().height(220.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Grafik Skor Depresi (7 Jurnal Terakhir)")
Canvas(modifier = Modifier.fillMaxWidth().height(150.dp)) {
val width = size.width; val height = size.height; val maxScore = 100f; val spacing = width / (dataPoints.size - 1).coerceAtLeast(1)
val path = Path()
dataPoints.forEachIndexed { index, score ->
val x = index * spacing; val y = height - (score / maxScore * height)
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
drawCircle(color = Color(0xFF4A90E2), radius = 8f, center = Offset(x, y))
}
drawPath(path, color = Color(0xFF4A90E2), style = Stroke(width = 5f))
}
}
}
}
}
class MyReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {}
}

View File

@ -1,62 +0,0 @@
package com.example.ppb_kelompok2
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class ReminderReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val title = intent.getStringExtra("title") ?: "MindTrack Reminder"
val message = intent.getStringExtra("message") ?: "Waktunya untuk check-in kesehatan mental Anda!"
showNotification(context, title, message)
}
private fun showNotification(context: Context, title: String, message: String) {
val channelId = "mindtrack_reminders"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "MindTrack Reminders"
val descriptionText = "Daily reminders for journaling and tests"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, name, importance).apply {
description = descriptionText
}
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(android.R.drawable.ic_dialog_info) // Ganti dengan icon aplikasi Anda jika ada
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
with(NotificationManagerCompat.from(context)) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
notify(1001, builder.build())
}
}
}
}

View File

@ -2,17 +2,10 @@ package com.example.ppb_kelompok2.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
// Pink Muda, Merah, dan Putih Theme
val PinkMuda = Color(0xFFFFD1DC) // Pink sangat muda
val MerahUtama = Color(0xFFFF5252) // Merah cerah
val MerahTua = Color(0xFFD32F2F) // Merah gelap untuk teks/aksen
val PutihMurni = Color(0xFFFFFFFF) // Putih
val PinkAksen = Color(0xFFFFB6C1) // Pink untuk variasi
// Tetap pertahankan default jika diperlukan (opsional)
val Purple80 = Color(0xFFD0BCFF) val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC) val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8) val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4) val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)

View File

@ -9,36 +9,35 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = MerahUtama, primary = Purple80,
secondary = PinkAksen, secondary = PurpleGrey80,
tertiary = PinkMuda, tertiary = Pink80
background = Color(0xFF1C1B1F),
surface = Color(0xFF1C1B1F),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.Black
) )
private val LightColorScheme = lightColorScheme( private val LightColorScheme = lightColorScheme(
primary = MerahUtama, primary = Purple40,
secondary = MerahTua, secondary = PurpleGrey40,
tertiary = PinkMuda, tertiary = Pink40
background = PutihMurni,
surface = PutihMurni, /* Other default colors to override
onPrimary = PutihMurni, background = Color(0xFFFFFBFE),
onSecondary = PutihMurni, surface = Color(0xFFFFFBFE),
onTertiary = Color.Black, onPrimary = Color.White,
surfaceVariant = PinkMuda onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
) )
@Composable @Composable
fun PPB_Kelompok2Theme( fun PPB_Kelompok2Theme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
@ -46,6 +45,7 @@ fun PPB_Kelompok2Theme(
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }
@ -55,4 +55,4 @@ fun PPB_Kelompok2Theme(
typography = Typography, typography = Typography,
content = content content = content
) )
} }

View File

@ -1,16 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Mental Health / Brain Icon Path -->
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,2.38 1.19,4.47 3,5.74V17c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-2.26c1.81,-1.27 3,-3.36 3,-5.74 0,-3.87 -3.13,-7 -7,-7zM14.5,13.88l-0.85,0.6V16h-3.3v-1.52l-0.85,-0.6C8.28,13.01 7.5,11.59 7.5,10.02c0,-2.48 2.02,-4.5 4.5,-4.5s4.5,2.02 4.5,4.5c0,1.57 -0.78,2.99 -2,3.86z"/>
<!-- Spark/Idea accents -->
<path
android:fillColor="#FFD700"
android:pathData="M12,6c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1z"/>
</vector>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/mindtrack_primary"/>
<foreground android:drawable="@drawable/ic_mindtrack_logo_foreground"/>
</adaptive-icon>

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/mindtrack_primary"/>
<foreground android:drawable="@drawable/ic_mindtrack_logo_foreground"/>
</adaptive-icon>

View File

@ -7,7 +7,4 @@
<color name="teal_700">#FF018786</color> <color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- MindTrack Brand Colors -->
<color name="mindtrack_primary">#4A90E2</color> <!-- Calming Blue -->
</resources> </resources>

View File

@ -3,5 +3,4 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.google.services) apply false
} }

View File

@ -8,12 +8,6 @@ espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1" lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0" activityCompose = "1.8.0"
composeBom = "2024.09.00" composeBom = "2024.09.00"
googleServices = "4.4.2"
firebaseBom = "33.1.2"
playServicesAuth = "20.7.0"
retrofit = "2.9.0"
okhttp = "4.12.0"
coil = "2.5.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -30,17 +24,9 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
firebase-auth-ktx = { group = "com.google.firebase", name = "firebase-auth-ktx" }
firebase-firestore-ktx = { group = "com.google.firebase", name = "firebase-firestore-ktx" }
play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" }