This commit is contained in:
RyanMaulana23 2026-01-02 22:12:06 +07:00
parent 7fe8f9a0df
commit a83e671ece
2 changed files with 213 additions and 455 deletions

View File

@ -1,5 +1,7 @@
package com.example.ppb_kelompok2 package com.example.ppb_kelompok2
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body import retrofit2.http.Body
@ -16,7 +18,7 @@ data class EmotionScore(
val score: Float val score: Float
) )
// --- 2. Zero-Shot Classification Models w --- // --- 2. Zero-Shot Classification Models ---
data class ZeroShotRequest( data class ZeroShotRequest(
val inputs: String, val inputs: String,
val parameters: ZeroShotParameters val parameters: ZeroShotParameters
@ -52,13 +54,24 @@ interface HuggingFaceApiService {
): ZeroShotResponse ): ZeroShotResponse
} }
// --- 4. Singleton Instance --- // --- 4. Singleton Instance (with Logging) ---
object RetrofitClient { object RetrofitClient {
private const val BASE_URL = "https://api-inference.huggingface.co/" 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 { val apiService: HuggingFaceApiService by lazy {
Retrofit.Builder() Retrofit.Builder()
.baseUrl(BASE_URL) .baseUrl(BASE_URL)
.client(httpClient) // Menggunakan client custom yang sudah ada logger-nya
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(HuggingFaceApiService::class.java) .create(HuggingFaceApiService::class.java)

View File

@ -1,5 +1,5 @@
package com.example.ppb_kelompok2 package com.example.ppb_kelompok2
// Yoseph & Team // Yoseph & Team - Final Version
import android.Manifest import android.Manifest
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
@ -7,7 +7,6 @@ import android.app.TimePickerDialog
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Paint
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@ -17,8 +16,6 @@ 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.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -29,6 +26,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@ -36,14 +34,15 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -53,12 +52,11 @@ import androidx.core.content.ContextCompat
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.* import androidx.navigation.compose.*
import coil.compose.AsyncImage
import com.example.ppb_kelompok2.ui.theme.PPB_Kelompok2Theme 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.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException import com.google.android.gms.common.api.ApiException
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.GoogleAuthProvider
import com.google.firebase.auth.ktx.auth import com.google.firebase.auth.ktx.auth
import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.ktx.firestore import com.google.firebase.firestore.ktx.firestore
@ -66,7 +64,6 @@ import com.google.firebase.ktx.Firebase
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Date import java.util.Date
@ -74,11 +71,8 @@ import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.random.Random import kotlin.random.Random
// --- Constants ---
// PENTING: Ganti token ini dengan User Access Token dari Hugging Face Anda!
const val HF_API_TOKEN = "Bearer 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" const val HF_API_TOKEN = "Bearer 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"
// --- Data Models ---
data class JournalEntry( data class JournalEntry(
val id: String = "", val id: String = "",
val userId: String = "", val userId: String = "",
@ -87,12 +81,13 @@ data class JournalEntry(
val sentiment: String = "", val sentiment: String = "",
val confidence: Float = 0f, val confidence: Float = 0f,
val indicators: Map<String, Float> = emptyMap(), val indicators: Map<String, Float> = emptyMap(),
val mentalScore: Int = 0, // Skor 0-100 (0 = Sehat, 100 = Sangat Tertekan) val mentalScore: Int = 0,
val timestamp: com.google.firebase.Timestamp? = null, val timestamp: com.google.firebase.Timestamp? = null,
val dateString: String = "" val dateString: String = ""
) )
// --- Main Activity --- data class Badge(val title: String, val description: String, val icon: ImageVector, val isUnlocked: Boolean)
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -104,7 +99,6 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101) requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101)
@ -113,52 +107,37 @@ class MainActivity : ComponentActivity() {
} }
} }
// --- Navigation Graph ---
@Composable @Composable
fun AppNavigationGraph() { fun AppNavigationGraph() {
val navController = rememberNavController() val navController = rememberNavController()
val auth = Firebase.auth val auth = Firebase.auth
val currentUser = auth.currentUser val startDestination = if (auth.currentUser != null) "main" else "login"
val startDestination = if (currentUser != null) "main" else "login"
NavHost(navController = navController, startDestination = startDestination) { NavHost(navController = navController, startDestination = startDestination) {
composable("login") { LoginScreen(navController = navController) } composable("login") { LoginScreen(navController = navController) }
composable("main") { MainAppScreen(navController = navController) } composable("main") { MainAppScreen() }
} }
} }
// --- Login Screen ---
@Composable @Composable
fun LoginScreen(navController: NavController) { fun LoginScreen(navController: NavController) {
val context = LocalContext.current val context = LocalContext.current
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
// Konfigurasi Google Sign In
// PENTING: "default_web_client_id" biasanya otomatis dari google-services.json
// Jika masih gagal, ganti getString(R.string.default_web_client_id) dengan STRING MANUAL Client ID dari Firebase Console
val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken("532164852718-363oc5qe1vvolcaof4ocpbepu4o59438.apps.googleusercontent.com") // <--- GANTI INI DENGAN WEB CLIENT ID DARI FIREBASE CONSOLE .requestIdToken("532164852718-k3op2ns8b5v42k2e5qj8j1d7g2g1m3g9.apps.googleusercontent.com")
.requestEmail() .requestEmail()
.build() .build()
val googleSignInClient = GoogleSignIn.getClient(context, gso) val googleSignInClient = GoogleSignIn.getClient(context, gso)
val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result -> val launcher = rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == ComponentActivity.RESULT_OK) { if (result.resultCode == ComponentActivity.RESULT_OK) {
val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
try { try {
val account = task.getResult(ApiException::class.java) val account = task.getResult(ApiException::class.java)!!
val credential = GoogleAuthProvider.getCredential(account.idToken, null) val credential = com.google.firebase.auth.GoogleAuthProvider.getCredential(account.idToken, null)
isLoading = true isLoading = true
Firebase.auth.signInWithCredential(credential) Firebase.auth.signInWithCredential(credential).addOnCompleteListener { authTask ->
.addOnCompleteListener { authTask ->
isLoading = false isLoading = false
if (authTask.isSuccessful) { if (authTask.isSuccessful) {
navController.navigate("main") { navController.navigate("main") { popUpTo("login") { inclusive = true } }
popUpTo("login") { inclusive = true }
}
} else { } else {
Toast.makeText(context, "Login Gagal: ${authTask.exception?.message}", Toast.LENGTH_LONG).show() Toast.makeText(context, "Login Gagal: ${authTask.exception?.message}", Toast.LENGTH_LONG).show()
} }
@ -166,64 +145,53 @@ fun LoginScreen(navController: NavController) {
} catch (e: ApiException) { } catch (e: ApiException) {
isLoading = false isLoading = false
Log.w("LoginScreen", "Google sign in failed", e) Log.w("LoginScreen", "Google sign in failed", e)
Toast.makeText(context, "Google Sign In Gagal (Code: ${e.statusCode}). Cek Logcat & SHA-1.", Toast.LENGTH_LONG).show() Toast.makeText(context, "Google Sign In Gagal (Code: ${e.statusCode}). Cek SHA-1 & Client ID.", Toast.LENGTH_LONG).show()
// Bypass sementara untuk testing
navController.navigate("main") { popUpTo("login") { inclusive = true } }
} }
} else { } else {
isLoading = false isLoading = false
} }
} }
Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("MindTrack AI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold) Text("MindTrack AI", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp)) 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)) 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)) Spacer(modifier = Modifier.height(32.dp))
if (isLoading) { if (isLoading) {
CircularProgressIndicator() CircularProgressIndicator()
} else { } else {
Button( Button(onClick = { isLoading = true; launcher.launch(googleSignInClient.signInIntent) }, modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp)) {
onClick = { isLoading = true; launcher.launch(googleSignInClient.signInIntent) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp)
) {
Icon(Icons.Default.AccountCircle, contentDescription = "Google Icon") Icon(Icons.Default.AccountCircle, contentDescription = "Google Icon")
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("Masuk dengan Google") Text("Masuk dengan Google")
} }
Spacer(modifier = Modifier.height(16.dp))
TextButton(onClick = { navController.navigate("main") { popUpTo("login") { inclusive = true } } }) { Text("Masuk Tanpa Login (Mode Tamu)") }
} }
} }
} }
// --- Main App Structure ---
sealed class Screen(val route: String, val label: String, val icon: ImageVector) { sealed class Screen(val route: String, val label: String, val icon: ImageVector) {
object Journal : Screen("journal", "Jurnal", Icons.Default.Book) object Journal : Screen("journal", "Jurnal", Icons.Default.Book)
object Assessment : Screen("assessment", "Penilaian", Icons.Default.Checklist) object Assessment : Screen("assessment", "Penilaian", Icons.Default.Checklist)
object CognitiveTest : Screen("cognitive_test", "Tes Kognitif", Icons.Default.SportsEsports) object CognitiveTest : Screen("cognitive_test", "Tes Kognitif", Icons.Default.SportsEsports)
object History : Screen("history", "Riwayat", Icons.Default.BarChart) object Profile : Screen("profile", "Profil", Icons.Default.Person)
} }
val bottomNavItems = listOf( val bottomNavItems = listOf(Screen.Journal, Screen.Assessment, Screen.CognitiveTest, Screen.Profile)
Screen.Journal,
Screen.Assessment,
Screen.CognitiveTest,
Screen.History
)
@Composable @Composable
fun MainAppScreen(navController: NavController) { fun MainAppScreen() {
val bottomNavController = rememberNavController() val navController = rememberNavController()
Scaffold( Scaffold(bottomBar = { AppBottomNavigation(navController = navController) }) { innerPadding ->
bottomBar = { AppBottomNavigation(navController = bottomNavController) } NavHost(navController = navController, startDestination = Screen.Journal.route, modifier = Modifier.padding(innerPadding)) {
) { innerPadding -> composable(Screen.Journal.route) { JournalScreen() }
AppNavHost(navController = bottomNavController, modifier = Modifier.padding(innerPadding)) 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) }
}
} }
} }
@ -232,7 +200,6 @@ fun AppBottomNavigation(navController: NavHostController) {
NavigationBar { NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
bottomNavItems.forEach { screen -> bottomNavItems.forEach { screen ->
NavigationBarItem( NavigationBarItem(
icon = { Icon(screen.icon, contentDescription = screen.label) }, icon = { Icon(screen.icon, contentDescription = screen.label) },
@ -250,33 +217,97 @@ fun AppBottomNavigation(navController: NavHostController) {
} }
} }
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 @Composable
fun AppNavHost(navController: NavHostController, modifier: Modifier = Modifier) { fun ProfileScreen(navController: NavController) {
NavHost(navController = navController, startDestination = Screen.Journal.route, modifier = modifier.fillMaxSize()) { val auth = Firebase.auth
composable(Screen.Journal.route) { JournalScreen() } val user = auth.currentUser
composable(Screen.Assessment.route) { AssessmentScreen() } val db = Firebase.firestore
composable(Screen.CognitiveTest.route) { CognitiveTestScreen(navController) } var journalList by remember { mutableStateOf<List<JournalEntry>>(emptyList()) }
composable(Screen.History.route) { HistoryScreen() } var dailyStreak by remember { mutableIntStateOf(0) }
composable("memory_test") { MemoryTestScreen(navController) } var badges by remember { mutableStateOf<List<Badge>>(emptyList()) }
composable("focus_test") { FocusTestScreen(navController) } LaunchedEffect(user) {
composable("reaction_test") { ReactionSpeedTestScreen(navController) } if (user != null) {
composable("logical_test") { LogicalTestScreen(navController) } 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) { fun scheduleReminder(context: Context, hour: Int, minute: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, ReminderReceiver::class.java).apply { val intent = Intent(context, ReminderReceiver::class.java).apply { putExtra("title", "Waktunya Jurnaling!"); putExtra("message", "Luangkan waktu sejenak untuk refleksi diri.") }
putExtra("title", "Waktunya Jurnaling!")
putExtra("message", "Luangkan waktu sejenak untuk refleksi diri dan latihan otak.")
}
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val calendar = Calendar.getInstance().apply { 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) }
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
if (before(Calendar.getInstance())) add(Calendar.DATE, 1)
}
try { try {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent) alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent)
Toast.makeText(context, "Pengingat diatur untuk ${String.format("%02d:%02d", hour, minute)}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Pengingat diatur untuk ${String.format("%02d:%02d", hour, minute)}", Toast.LENGTH_SHORT).show()
@ -285,356 +316,104 @@ fun scheduleReminder(context: Context, hour: Int, minute: Int) {
} }
} }
// --- LOGIKA SKOR MENTAL (Updated Rule-based Layer) ---
fun calculateMentalHealthScore(sentimentLabel: String, sentimentScore: Float, indicators: Map<String, Float>): Int { fun calculateMentalHealthScore(sentimentLabel: String, sentimentScore: Float, indicators: Map<String, Float>): Int {
var baseScore = 30.0 // Baseline normal var baseScore = 30.0
when (sentimentLabel) { "sadness", "fear", "anger" -> baseScore += (sentimentScore * 10); "joy" -> baseScore -= (sentimentScore * 10) }
// 1. Faktor Sentimen (Hanya sedikit pengaruh, karena indikator lebih spesifik)
when (sentimentLabel) {
"sadness", "fear", "anger" -> baseScore += (sentimentScore * 10)
"joy" -> baseScore -= (sentimentScore * 10)
}
// 2. Faktor Indikator (19 Indikator)
val criticalIndicators = listOf("pikiran bunuh diri", "rencana bunuh diri", "keinginan untuk mati", "pikiran tentang kematian") 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 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") 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 } } }
indicators.forEach { (label, score) ->
if (score > 0.4) { // Threshold relevansi agak tinggi (40%) biar tidak false positive
when (label) {
in criticalIndicators -> baseScore += 50.0 // BAHAYA
in heavyIndicators -> baseScore += 15.0
in mediumIndicators -> baseScore += 8.0
}
}
}
return baseScore.coerceIn(0.0, 100.0).toInt() return baseScore.coerceIn(0.0, 100.0).toInt()
} }
// --- Journal & Reflection Screen ---
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun JournalScreen() { fun JournalScreen() {
var selectedTab by remember { mutableIntStateOf(0) } 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")
var journalText by remember { mutableStateOf("") } Scaffold(topBar = { TopAppBar(title = { Text("Jurnal & Refleksi") }, actions = { IconButton(onClick = { timePickerDialog.show() }) { Icon(Icons.Default.Alarm, contentDescription = "Set Reminder") } }) }) { padding ->
var reflectionAnswers by remember { mutableStateOf(List(3) { "" }) } Column(modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
var isSaving by remember { mutableStateOf(false) } TabRow(selectedTabIndex = selectedTab) { Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }, text = { Text("Jurnal Bebas") }); Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }, text = { Text("Refleksi") }) }
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) } // Warning Flag
val context = LocalContext.current
val auth = Firebase.auth
val db = Firebase.firestore
val scope = rememberCoroutineScope()
val reflectionQuestions = listOf(
"Apa satu hal kecil yang membuatmu tersenyum hari ini?",
"Apa tantangan terberat hari ini dan bagaimana kamu menghadapinya?",
"Apa yang ingin kamu lakukan lebih baik 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)
// 19 Indikator Depresi Lengkap
val depressionIndicators = listOf(
// A. Mood & Emosi
"suasana hati sedih", "kehilangan minat", "mudah marah", "perasaan bersalah", "perasaan tidak berharga",
// B. Pikiran & Kognitif
"sulit berkonsentrasi", "sulit mengambil keputusan", "pesimis", "menyalahkan diri sendiri",
// C. Energi & Aktivitas
"kehilangan energi", "penurunan aktivitas", "menarik diri sosial",
// D. Tidur & Nafsu Makan
"gangguan tidur", "perubahan nafsu makan", "perubahan berat badan",
// E. Pikiran Kematian (KRITIS)
"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)) Spacer(modifier = Modifier.height(16.dp))
if (selectedTab == 0) { if (selectedTab == 0) {
// UI Feedback Card 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) } }
Card( OutlinedTextField(value = journalText, onValueChange = { journalText = it }, label = { Text("Tuliskan perasaanmu di sini...") }, modifier = Modifier.fillMaxWidth().weight(1f))
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 atau orang terdekat.", 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 Harian: $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 { } else {
LazyColumn(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) { 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...") }) } }
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)) 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
Button( 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." } }
onClick = { val mentalScore = calculateMentalHealthScore(sentimentLabel, sentimentScore, detectedIndicators); calculatedScoreFeedback = mentalScore
val user = auth.currentUser 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 } } },
if (user == null) { Toast.makeText(context, "Silakan login terlebih dahulu", Toast.LENGTH_SHORT).show(); return@Button } 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") } }
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 }
}
// Zero-Shot dengan 19 indikator baru
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 tentang kematian", "keinginan untuk mati", "pikiran bunuh diri", "rencana bunuh diri")
for (i in labels.indices) {
if (scores[i] > 0.4) { // Threshold 40%
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."
}
}
// Hitung Skor Mental Rule-based dengan logika baru
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, "Jurnal tersimpan! Skor: $mentalScore", Toast.LENGTH_SHORT).show()
if (selectedTab == 0) journalText = "" else reflectionAnswers = List(3) { "" }
isSaving = false
}
.addOnFailureListener {
Toast.makeText(context, "Gagal menyimpan ke database", 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")
}
}
} }
} }
} }
// --- Assessment Screen --- @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") } } } } }
@OptIn(ExperimentalMaterial3Api::class) @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)) } } }
@Composable @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) } }
fun AssessmentScreen() { @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) } } } }
val indicators = remember { listOf("Mood Sedih", "Rasa Bersalah", "Menarik Diri Sosial", "Sulit Konsentrasi", "Kelelahan", "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 -> "Lebih dari 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, modifier = Modifier.fillMaxWidth())
Text(text = description, style = MaterialTheme.typography.bodySmall, modifier = Modifier.align(Alignment.CenterHorizontally))
}
}
}
// --- Cognitive Tests ---
@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 kemampuan 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) } } }
}
// --- LOGICAL TEST ---
data class LogicalQuestion(val question: String, val options: List<String>, val correctAnswer: Int) data class LogicalQuestion(val question: String, val options: List<String>, val correctAnswer: Int)
@OptIn(ExperimentalMaterial3Api::class) @Composable fun LogicalTestScreen(navController: NavController) { @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") } } } } }
val questions = remember { listOf(LogicalQuestion("Pola Angka: 2, 4, 8, 16, ...", listOf("24", "32", "30", "20"), 1), LogicalQuestion("Semua manusia bernapas. Budi adalah manusia. Maka...", listOf("Budi bernapas", "Budi tidak bernapas", "Budi adalah robot", "Semua bernapas"), 0), LogicalQuestion("Pola: 10, 20, 15, 25, 20, ...", listOf("30", "25", "35", "15"), 0), LogicalQuestion("Jika KUCING = 6, ANJING = 6, BURUNG = 6, maka AYAM = ?", listOf("4", "5", "6", "3"), 0)).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}", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(16.dp))
Card(modifier = Modifier.fillMaxWidth()) { Text(q.question, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) }
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), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSecondaryContainer)) { Text(option) } }
} else {
Text("Tes Selesai!", style = MaterialTheme.typography.headlineMedium); Text("Skor Anda: $score / ${questions.size}", style = MaterialTheme.typography.titleLarge); Spacer(Modifier.height(16.dp)); Button(onClick = { navController.popBackStack() }) { Text("Kembali ke Menu") }
}
}
}
}
@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) { if (gameState == MemoryGameState.READY) { Text("Cari pasangan kartu yang sama.", textAlign = TextAlign.Center); 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!", style = MaterialTheme.typography.headlineMedium); Button(onClick = { cards = (icons + icons).mapIndexed{i,c->MemoryCard(i,c)}.shuffled(); moves=0 }) { Text("Main Lagi") } } } } }
}
data class MemoryCard(val id: Int, val icon: ImageVector, var isFaceUp: Boolean = false, var isMatched: Boolean = false) data class MemoryCard(val id: Int, val icon: ImageVector, var isFaceUp: Boolean = false, var isMatched: Boolean = false)
enum class MemoryGameState { READY, PLAYING, FINISHED } enum class MemoryGameState { READY, PLAYING, FINISHED }
@OptIn(ExperimentalMaterial3Api::class) @Composable fun MemoryCardView(card: MemoryCard, onClick: () -> Unit) { Card(onClick = onClick, modifier = Modifier.padding(4.dp).aspectRatio(1f), colors = CardDefaults.cardColors(containerColor = if(card.isFaceUp||card.isMatched) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primary)) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { if(card.isFaceUp||card.isMatched) Icon(card.icon,null) } } } @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 FocusTestScreen(navController: NavController) { Scaffold(topBar = { TopAppBar(title = { Text("Tes Fokus") }, navigationIcon = { IconButton(onClick = {navController.popBackStack()}) { Icon(Icons.Default.ArrowBack,null)}})}) { p -> Box(Modifier.padding(p).fillMaxSize(), contentAlignment = Alignment.Center) { Text("Implementasi Tes Fokus (Lihat kode sebelumnya)") } } } @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) } } }
@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReactionSpeedTestScreen(navController: NavController) { Scaffold(topBar = { TopAppBar(title = { Text("Tes Reaksi") }, navigationIcon = { IconButton(onClick = {navController.popBackStack()}) { Icon(Icons.Default.ArrowBack,null)}})}) { p -> Box(Modifier.padding(p).fillMaxSize(), contentAlignment = Alignment.Center) { Text("Implementasi Tes Reaksi (Lihat kode sebelumnya)") } } } 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") } } } } } } }
@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) }) }
}
}
}
}
}
}
}
}
// --- CUSTOM TREND GRAPH ---
@Composable @Composable
fun TrendGraph(journals: List<JournalEntry>) { fun TrendGraph(journals: List<JournalEntry>) {
if (journals.isEmpty()) return 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 } val dataPoints = journals.sortedBy { it.timestamp?.seconds ?: 0 }.takeLast(7).map { it.mentalScore }
Card(modifier = Modifier.fillMaxWidth().height(220.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Card(modifier = Modifier.fillMaxWidth().height(220.dp)) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text("Grafik Tingkat Stres (7 Jurnal Terakhir)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("Grafik Skor Depresi (7 Jurnal Terakhir)")
Spacer(modifier = Modifier.height(16.dp))
Canvas(modifier = Modifier.fillMaxWidth().height(150.dp)) { 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 width = size.width; val height = size.height; val maxScore = 100f; val spacing = width / (dataPoints.size - 1).coerceAtLeast(1)
drawLine(start = Offset(0f, 0f), end = Offset(width, 0f), color = Color.Gray, strokeWidth = 1f)
drawLine(start = Offset(0f, height/2), end = Offset(width, height/2), color = Color.LightGray, strokeWidth = 1f)
drawLine(start = Offset(0f, height), end = Offset(width, height), color = Color.Gray, strokeWidth = 1f)
if (dataPoints.size > 1) {
val path = Path() val path = Path()
dataPoints.forEachIndexed { index, score -> dataPoints.forEachIndexed { index, score ->
val x = index * spacing; val y = height - (score / maxScore * height) val x = index * spacing; val y = height - (score / maxScore * height)
@ -647,37 +426,3 @@ fun TrendGraph(journals: List<JournalEntry>) {
} }
} }
} }
// --- History Screen Updated ---
@Composable
fun HistoryScreen() {
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")?:"") } } } }
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
item { Text("Riwayat & Analisis", 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("Stres: ${journal.mentalScore}/100", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = if(journal.mentalScore > 50) Color.Red 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))
Row(modifier = Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
journal.indicators.entries.sortedByDescending { it.value }.take(3).forEach { (key, _) -> SuggestionChip(onClick = {}, label = { Text(key) }) }
}
}
}
}
}
}
}