From 7fe8f9a0df41b7e8ae52348f042594c987f2ed1b Mon Sep 17 00:00:00 2001 From: RyanMaulana23 Date: Mon, 22 Dec 2025 17:30:31 +0700 Subject: [PATCH] Oauth, Model ML, Firebase --- .idea/.name | 1 + .idea/codeStyles/Project.xml | 123 ++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/studiobot.xml | 6 + app/build.gradle.kts | 8 + app/google-services.json | 29 + app/src/main/AndroidManifest.xml | 13 +- .../example/ppb_kelompok2/HuggingFaceApi.kt | 66 + .../com/example/ppb_kelompok2/MainActivity.kt | 1175 +++++++---------- .../example/ppb_kelompok2/ReminderReceiver.kt | 62 + .../drawable/ic_mindtrack_logo_foreground.xml | 16 + .../ic_launcher_mindtrack.xml | 5 + .../ic_launcher_mindtrack_round.xml | 5 + app/src/main/res/values/colors.xml | 3 + build.gradle.kts | 1 + gradle/libs.versions.toml | 14 +- 16 files changed, 849 insertions(+), 683 deletions(-) create mode 100644 .idea/.name create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/studiobot.xml create mode 100644 app/google-services.json create mode 100644 app/src/main/java/com/example/ppb_kelompok2/HuggingFaceApi.kt create mode 100644 app/src/main/java/com/example/ppb_kelompok2/ReminderReceiver.kt create mode 100644 app/src/main/res/drawable/ic_mindtrack_logo_foreground.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack_round.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..76d0635 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +PPB_Kelompok2 \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml new file mode 100644 index 0000000..539e3b8 --- /dev/null +++ b/.idea/studiobot.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3f21c8..09b33cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.google.services) } android { @@ -52,6 +53,13 @@ dependencies { implementation(libs.androidx.compose.material3) implementation("androidx.compose.material:material-icons-extended") 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) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..cd34937 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,29 @@ +{ + "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" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2c56ca6..51c117c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,13 +2,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/ppb_kelompok2/HuggingFaceApi.kt b/app/src/main/java/com/example/ppb_kelompok2/HuggingFaceApi.kt new file mode 100644 index 0000000..07fb3b2 --- /dev/null +++ b/app/src/main/java/com/example/ppb_kelompok2/HuggingFaceApi.kt @@ -0,0 +1,66 @@ +package com.example.ppb_kelompok2 + +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> + +data class EmotionScore( + val label: String, + val score: Float +) + +// --- 2. Zero-Shot Classification Models w --- +data class ZeroShotRequest( + val inputs: String, + val parameters: ZeroShotParameters +) + +data class ZeroShotParameters( + val candidate_labels: List, + val multi_label: Boolean = true // True = satu teks bisa masuk ke banyak kategori +) + +data class ZeroShotResponse( + val sequence: String, + val labels: List, + val scores: List +) + +// --- 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 --- +object RetrofitClient { + private const val BASE_URL = "https://api-inference.huggingface.co/" + + val apiService: HuggingFaceApiService by lazy { + Retrofit.Builder() + .baseUrl(BASE_URL) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(HuggingFaceApiService::class.java) + } +} diff --git a/app/src/main/java/com/example/ppb_kelompok2/MainActivity.kt b/app/src/main/java/com/example/ppb_kelompok2/MainActivity.kt index d4c5490..588cdee 100644 --- a/app/src/main/java/com/example/ppb_kelompok2/MainActivity.kt +++ b/app/src/main/java/com/example/ppb_kelompok2/MainActivity.kt @@ -1,11 +1,25 @@ package com.example.ppb_kelompok2 -// Yoseph +// Yoseph & Team +import android.Manifest +import android.app.AlarmManager +import android.app.PendingIntent +import android.app.TimePickerDialog +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Paint +import android.os.Build import android.os.Bundle +import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +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.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -14,6 +28,8 @@ 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.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -21,20 +37,61 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.nativeCanvas +import androidx.compose.ui.graphics.toArgb 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 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.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +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 kotlinx.coroutines.tasks.await +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 --- +// PENTING: Ganti token ini dengan User Access Token dari Hugging Face Anda! +const val HF_API_TOKEN = "Bearer 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" + +// --- 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 = emptyMap(), + val mentalScore: Int = 0, // Skor 0-100 (0 = Sehat, 100 = Sangat Tertekan) + val timestamp: com.google.firebase.Timestamp? = null, + val dateString: String = "" +) + // --- Main Activity --- class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -47,6 +104,12 @@ class MainActivity : ComponentActivity() { } } } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 101) + } + } } } @@ -54,9 +117,14 @@ class MainActivity : ComponentActivity() { @Composable fun AppNavigationGraph() { val navController = rememberNavController() - NavHost(navController = navController, startDestination = "login") { + val auth = Firebase.auth + val currentUser = auth.currentUser + + val startDestination = if (currentUser != null) "main" else "login" + + NavHost(navController = navController, startDestination = startDestination) { composable("login") { LoginScreen(navController = navController) } - composable("main") { MainAppScreen() } + composable("main") { MainAppScreen(navController = navController) } } } @@ -64,43 +132,82 @@ fun AppNavigationGraph() { // --- Login Screen --- @Composable fun LoginScreen(navController: NavController) { + val context = LocalContext.current + 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) + .requestIdToken("532164852718-363oc5qe1vvolcaof4ocpbepu4o59438.apps.googleusercontent.com") // <--- GANTI INI DENGAN WEB CLIENT ID DARI FIREBASE CONSOLE + .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 = 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 (Code: ${e.statusCode}). Cek Logcat & SHA-1.", Toast.LENGTH_LONG).show() + // Bypass sementara untuk testing + navController.navigate("main") { popUpTo("login") { inclusive = true } } + } + } else { + isLoading = false + } + } + Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + 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 Anda dengan kekuatan AI.", - 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)) - Button( - onClick = { navController.navigate("main") { - popUpTo("login") { inclusive = true } - } }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp) - ) { - Icon(Icons.Default.AccountCircle, contentDescription = "Google Icon") // Placeholder for Google Icon - Spacer(modifier = Modifier.width(8.dp)) - Text("Masuk dengan Google") + + 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") + } + Spacer(modifier = Modifier.height(16.dp)) + TextButton(onClick = { navController.navigate("main") { popUpTo("login") { inclusive = true } } }) { Text("Masuk Tanpa Login (Mode Tamu)") } } } } -// --- Main App Structure (with Bottom Navigation) --- +// --- Main App Structure --- 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 History : Screen("history", "Riwayat & Grafik", Icons.Default.BarChart) + object History : Screen("history", "Riwayat", Icons.Default.BarChart) } val bottomNavItems = listOf( @@ -111,12 +218,12 @@ val bottomNavItems = listOf( ) @Composable -fun MainAppScreen() { - val navController = rememberNavController() +fun MainAppScreen(navController: NavController) { + val bottomNavController = rememberNavController() Scaffold( - bottomBar = { AppBottomNavigation(navController = navController) } + bottomBar = { AppBottomNavigation(navController = bottomNavController) } ) { innerPadding -> - AppNavHost(navController = navController, modifier = Modifier.padding(innerPadding)) + AppNavHost(navController = bottomNavController, modifier = Modifier.padding(innerPadding)) } } @@ -145,11 +252,7 @@ fun AppBottomNavigation(navController: NavHostController) { @Composable fun AppNavHost(navController: NavHostController, modifier: Modifier = Modifier) { - NavHost( - navController = navController, - startDestination = Screen.Journal.route, - modifier = modifier.fillMaxSize() - ) { + NavHost(navController = navController, startDestination = Screen.Journal.route, modifier = modifier.fillMaxSize()) { composable(Screen.Journal.route) { JournalScreen() } composable(Screen.Assessment.route) { AssessmentScreen() } composable(Screen.CognitiveTest.route) { CognitiveTestScreen(navController) } @@ -157,712 +260,424 @@ fun AppNavHost(navController: NavHostController, modifier: Modifier = Modifier) composable("memory_test") { MemoryTestScreen(navController) } composable("focus_test") { FocusTestScreen(navController) } composable("reaction_test") { ReactionSpeedTestScreen(navController) } + composable("logical_test") { LogicalTestScreen(navController) } } } -// --- App Screens --- +fun scheduleReminder(context: Context, hour: Int, minute: Int) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, ReminderReceiver::class.java).apply { + 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 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 { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, calendar.timeInMillis, pendingIntent) + Toast.makeText(context, "Pengingat diatur untuk ${String.format("%02d:%02d", hour, minute)}", Toast.LENGTH_SHORT).show() + } catch (e: SecurityException) { + Toast.makeText(context, "Izin alarm tidak diberikan", Toast.LENGTH_SHORT).show() + } +} -@Composable -fun JournalScreen() { - var journalText by remember { mutableStateOf("") } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Card(modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp)) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Analisis AI", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(8.dp)) - Text("Sentimen: Netral") - Text("Emosi Terdeteksi: Tenang") +// --- LOGIKA SKOR MENTAL (Updated Rule-based Layer) --- +fun calculateMentalHealthScore(sentimentLabel: String, sentimentScore: Float, indicators: Map): Int { + var baseScore = 30.0 // Baseline normal + + // 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 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) { // 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 } } - OutlinedTextField( - value = journalText, - onValueChange = { journalText = it }, - label = { Text("Tuliskan perasaanmu di sini...") }, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { /* TODO: Implement save logic */ }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Simpan Jurnal") - } } + + return baseScore.coerceIn(0.0, 100.0).toInt() } +// --- Journal & Reflection Screen --- @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AssessmentScreen() { - val indicators = remember { - listOf( - "Mood Sedih", "Rasa Bersalah", "Menarik Diri Sosial", - "Sulit Konsentrasi", "Kelelahan", "Pikiran Bunuh Diri" - ) - } +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(null) } + var detectedIssues by remember { mutableStateOf(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 sliderValues = remember { - mutableStateMapOf().apply { - indicators.forEach { indicator -> - put(indicator, 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" - } + 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("Penilaian Harian") }) + TopAppBar( + title = { Text("Jurnal & Refleksi") }, + actions = { IconButton(onClick = { timePickerDialog.show() }) { Icon(Icons.Default.Alarm, contentDescription = "Set Reminder") } } + ) } - ) { innerPadding -> + ) { 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) { + // 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 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 { + 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, "Silakan login terlebih dahulu", 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() + + 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() + 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 Sosial", "Sulit Konsentrasi", "Kelelahan", "Pikiran Bunuh Diri") } + val sliderValues = remember { mutableStateMapOf().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), + 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), - horizontalAlignment = Alignment.Start - ) { + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { + Column(modifier = Modifier.padding(16.dp)) { Text("Ringkasan Skor", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) Text("Total Skor: $totalScore", style = MaterialTheme.typography.headlineMedium) Text("Tingkat: $assessmentLevel", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) } } } - - items(indicators) { indicatorName -> - IndicatorItem( - indicatorName = indicatorName, - value = sliderValues[indicatorName] ?: 0f, - onValueChange = { - sliderValues[indicatorName] = it.roundToInt().toFloat() - } - ) - } - - item { - Button( - onClick = { /* TODO: Implement finish logic */ }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Selesai") - } - } + 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 -> "" - } - +@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, - verticalAlignment = Alignment.CenterVertically - ) { + 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 - ) + Text(text = value.toInt().toString(), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) } - Spacer(modifier = Modifier.height(8.dp)) - - Slider( - value = value, - onValueChange = onValueChange, - valueRange = 0f..3f, - steps = 2, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.CenterHorizontally) - ) + 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)) } } } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CognitiveTestScreen(navController: NavController) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { +// --- 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) - - @Composable - fun TestCard(title: String, description: String, icon: ImageVector, route: String) { - Card( - onClick = { navController.navigate(route) }, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(icon, contentDescription = 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) - } - } - } - } - - TestCard( - title = "Tes Memori", - description = "Uji memori jangka pendek Anda", - icon = Icons.Default.Memory, - route = "memory_test" - ) - TestCard( - title = "Tes Fokus", - description = "Uji kemampuan fokus & atensi", - icon = Icons.Default.CenterFocusStrong, - route = "focus_test" - ) - TestCard( - title = "Tes Kecepatan Reaksi", - description = "Uji kecepatan reaksi visual Anda", - icon = Icons.Default.Speed, - route = "reaction_test" - ) + 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) } } } +} -// --- Cognitive Test Screens --- - -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(createShuffledCards(icons)) } +// --- LOGICAL TEST --- +data class LogicalQuestion(val question: String, val options: List, val correctAnswer: Int) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LogicalTestScreen(navController: NavController) { + 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()) } var moves by remember { mutableIntStateOf(0) } - var bestScore by remember { mutableStateOf(null) } var gameState by remember { mutableStateOf(MemoryGameState.READY) } - - LaunchedEffect(cards.all { it.isMatched }) { - if (cards.all { it.isMatched } && gameState == MemoryGameState.PLAYING) { - gameState = MemoryGameState.FINISHED - if (bestScore == null || moves < bestScore!!) { - bestScore = moves - } - } - } - - LaunchedEffect(selectedCards) { - if (selectedCards.size == 2) { - val (first, second) = selectedCards - if (first.icon == second.icon) { - cards = cards.map { if (it.id == first.id || it.id == second.id) it.copy(isMatched = true) else it } - } else { - delay(1000) - cards = cards.map { if (it.id == first.id || it.id == second.id) it.copy(isFaceUp = false) else it } - } - selectedCards = listOf() - } - } - - fun restartGame() { - moves = 0 - cards = createShuffledCards(icons) - gameState = MemoryGameState.PLAYING - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Tes Memori") }, - navigationIcon = { - IconButton(onClick = { navController.popBackStack() }) { - Icon(Icons.Default.ArrowBack, contentDescription = "Kembali") - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (gameState) { - MemoryGameState.READY -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Tes Memori", style = MaterialTheme.typography.headlineMedium) - bestScore?.let { - Text("Skor Terbaik: $it gerakan", style = MaterialTheme.typography.titleMedium) - } - Text( - "Tes ini menguji memori jangka pendek Anda. Cocokkan semua kartu dengan jumlah gerakan sesedikit mungkin.", - textAlign = TextAlign.Center - ) - Button(onClick = { gameState = MemoryGameState.PLAYING }) { - Text("Mulai") - } - } - } - - MemoryGameState.PLAYING, MemoryGameState.FINISHED -> { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { - Text("Gerakan: $moves", style = MaterialTheme.typography.bodyLarge) - bestScore?.let { - Text("Skor Terbaik: $it", style = MaterialTheme.typography.bodyLarge) - } - } - Spacer(modifier = Modifier.height(16.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(3), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(cards) { card -> - MemoryCardView(card = card, onCardClicked = { - if (gameState == MemoryGameState.PLAYING && !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++ - } - }) - } - } - Spacer(modifier = Modifier.weight(1f)) - if (gameState == MemoryGameState.FINISHED) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)){ - Text("Selesai!", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.primary) - Button(onClick = { restartGame() }) { - Icon(Icons.Default.Refresh, contentDescription = "Coba Lagi") - Spacer(modifier = Modifier.width(8.dp)) - Text("Coba Lagi") - } - } - } - } - } - } - } + 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) +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 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 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)") } } } -fun createShuffledCards(icons: List): List { - return (icons + icons).mapIndexed { index, icon -> MemoryCard(id = index, icon = icon) }.shuffled() -} - -@OptIn(ExperimentalMaterial3Api::class) +// --- CUSTOM TREND GRAPH --- @Composable -fun MemoryCardView(card: MemoryCard, onCardClicked: () -> Unit) { - Card( - onClick = onCardClicked, - modifier = Modifier.aspectRatio(1f), - enabled = !card.isMatched, - colors = CardDefaults.cardColors( - containerColor = if (card.isFaceUp || card.isMatched) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primary - ) - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - if (card.isFaceUp || card.isMatched) { - Icon(card.icon, contentDescription = null, modifier = Modifier.size(40.dp)) - } - } - } -} - -enum class FocusGameState { READY, PLAYING, FINISHED } - -@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, contentDescription = "Kembali") - } - } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - when (gameState) { - FocusGameState.READY -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Tes Fokus", style = MaterialTheme.typography.headlineMedium) - if (highScore > 0) { - Text("Skor Tertinggi: $highScore", style = MaterialTheme.typography.titleMedium) - } - Text( - "Tes ini menguji kemampuan fokus dan atensi Anda untuk mengidentifikasi perbedaan visual dengan cepat.", - textAlign = TextAlign.Center - ) - Text("Pilih durasi waktu:") - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val durations = listOf(15, 30, 60, 120) - durations.forEach { duration -> - val isSelected = selectedDuration == duration - OutlinedButton( - onClick = { selectedDuration = duration }, - colors = if (isSelected) ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ButtonDefaults.outlinedButtonColors() - ) { - Text("${duration}s") - } - } - } - Button(onClick = { gameState = FocusGameState.PLAYING }) { - Text("Mulai") - } - } - } - FocusGameState.PLAYING -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Skor: $score", style = MaterialTheme.typography.bodyLarge) - Text("Waktu: $timeLeft", style = MaterialTheme.typography.bodyLarge) - } - Spacer(modifier = Modifier.height(16.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(5), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(gridItems.indices.toList()) { index -> - val item = gridItems[index] - Icon( - imageVector = item.icon, - contentDescription = null, - modifier = Modifier - .size(40.dp) - .rotate(item.rotation) - .clickable { - if (item.isDistractor) { - score++ - newLevel() - } else { - if (score > 0) score-- - } - }, - tint = item.color - ) - } - } - } - FocusGameState.FINISHED -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Waktu Habis!", style = MaterialTheme.typography.headlineMedium) - Text("Skor Akhir: $score", style = MaterialTheme.typography.bodyLarge) - Text("Skor Tertinggi: $highScore", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) - Button(onClick = { restartGame() }) { - Text("Coba Lagi") - } +fun TrendGraph(journals: List) { + if (journals.isEmpty()) return + 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)) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Grafik Tingkat Stres (7 Jurnal Terakhir)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(16.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) + 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() + 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)) } } } } } -data class FocusItem(val icon: ImageVector, val color: Color, val rotation: Float, val isDistractor: Boolean) - -private fun generateFocusGrid(normalColor: Color): List { - 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 -> { // Different Icon - distractor = FocusItem(Icons.Default.Star, normalColor, 0f, true) - } - 1 -> { // Different Color - distractor = FocusItem(normalIcon, Color.Red, 0f, true) - } - else -> { // Different Rotation - distractor = FocusItem(normalIcon, normalColor, 90f, true) - // Use an icon that shows rotation - 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(null) } - - val backgroundColor by animateColorAsState( - targetValue = when (state) { - ReactionGameState.WAITING -> Color.Red.copy(alpha = 0.8f) - ReactionGameState.ACTION -> Color.Green.copy(alpha = 0.8f) - else -> MaterialTheme.colorScheme.surface - }, - animationSpec = tween(300), - label = "ReactionBackgroundColor" - ) - - val onScreenClick = { - when (state) { - ReactionGameState.WAITING -> { - reactionTime = -1 // Too soon - state = ReactionGameState.FINISHED - } - ReactionGameState.ACTION -> { - val newReactionTime = System.currentTimeMillis() - startTime - reactionTime = newReactionTime - if (bestTime == null || newReactionTime < bestTime!!) { - bestTime = newReactionTime - } - state = ReactionGameState.FINISHED - } - else -> { /* Clicks handled by buttons in READY and FINISHED states */ } - } - } - - LaunchedEffect(state) { - if (state == ReactionGameState.WAITING) { - delay(Random.nextLong(1500, 5500)) - if (state == ReactionGameState.WAITING) { // Ensure state hasn't changed - startTime = System.currentTimeMillis() - state = ReactionGameState.ACTION - } - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Tes Kecepatan Reaksi") }, - navigationIcon = { - IconButton(onClick = { navController.popBackStack() }) { - Icon(Icons.Default.ArrowBack, contentDescription = "Kembali") - } - } - ) - } - ) { 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( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp) - ) { - Text("Tes Kecepatan Reaksi", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface) - bestTime?.let { - Text("Waktu Terbaik: $it ms", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) - } - Text( - "Tes ini mengukur kecepatan reaksi visual Anda. Tunggu layar berubah menjadi hijau, lalu tekan secepat mungkin.", - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.onSurface - ) - Button(onClick = { state = ReactionGameState.WAITING }) { - Text("Mulai") - } - } - } - ReactionGameState.WAITING -> { - Text("Tunggu sampai hijau...", fontSize = 24.sp, color = Color.White, fontWeight = FontWeight.Bold) - } - ReactionGameState.ACTION -> { - Text("Tekan Sekarang!", fontSize = 24.sp, color = Color.White, fontWeight = FontWeight.Bold) - } - ReactionGameState.FINISHED -> { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp) - ) { - val resultText = if (reactionTime == -1L) "Terlalu Cepat!" else "${reactionTime} ms" - Text(resultText, fontSize = 48.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) - bestTime?.let { - Text("Waktu Terbaik: $it ms", fontSize = 20.sp, color = MaterialTheme.colorScheme.onSurface) - } - Button(onClick = { state = ReactionGameState.READY }) { - Text("Coba Lagi") - } - } - } - } - } - } -} - - +// --- History Screen Updated --- @Composable fun HistoryScreen() { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - Text("Riwayat & Grafik", style = MaterialTheme.typography.headlineSmall) - } - item { - GraphCard(title = "Tren Mood Mingguan") - } - item { - GraphCard(title = "Perkembangan Skor Depresi") - } - item { + val auth = Firebase.auth + val db = Firebase.firestore + var journalList by remember { mutableStateOf>(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 ?: 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)) { - Text("Insight AI", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(8.dp)) - Text("AI menemukan pola bahwa mood Anda cenderung menurun di akhir pekan.", style = MaterialTheme.typography.bodyMedium) + 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) }) } + } + } } } } } } - -@Composable -fun GraphCard(title: String) { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .height(150.dp) - .background(Color.LightGray.copy(alpha = 0.5f)) - ) { - Text("Area Grafik", modifier = Modifier.align(Alignment.Center), color = Color.Gray) - } - } - } -} diff --git a/app/src/main/java/com/example/ppb_kelompok2/ReminderReceiver.kt b/app/src/main/java/com/example/ppb_kelompok2/ReminderReceiver.kt new file mode 100644 index 0000000..fb5f0fc --- /dev/null +++ b/app/src/main/java/com/example/ppb_kelompok2/ReminderReceiver.kt @@ -0,0 +1,62 @@ +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()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mindtrack_logo_foreground.xml b/app/src/main/res/drawable/ic_mindtrack_logo_foreground.xml new file mode 100644 index 0000000..bf23d5a --- /dev/null +++ b/app/src/main/res/drawable/ic_mindtrack_logo_foreground.xml @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack.xml new file mode 100644 index 0000000..5c402ae --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack_round.xml new file mode 100644 index 0000000..5c402ae --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_mindtrack_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..0e21c81 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,7 @@ #FF018786 #FF000000 #FFFFFFFF + + + #4A90E2 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..5dc087f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.google.services) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7e6b91..5aaf235 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,11 @@ espressoCore = "3.5.1" lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.8.0" composeBom = "2024.09.00" +googleServices = "4.4.2" +firebaseBom = "33.1.2" +playServicesAuth = "20.7.0" +retrofit = "2.9.0" +okhttp = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,9 +29,16 @@ 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-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", 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" }