Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb12f138b0 | |||
| 6c2d32af30 |
123
.idea/codeStyles/Project.xml
generated
123
.idea/codeStyles/Project.xml
generated
@ -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>
|
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@ -1,5 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<state>
|
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
|
||||||
</state>
|
|
||||||
</component>
|
|
||||||
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@ -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
6
.idea/studiobot.xml
generated
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="StudioBotProjectSettings">
|
|
||||||
<option name="shareContext" value="OptedIn" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
893
app/src/main/assets/index.html
Normal file
893
app/src/main/assets/index.html
Normal 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>
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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) {}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
||||||
@ -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
|
|
||||||
}
|
}
|
||||||
@ -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" }
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user