feat: implement plant disease detection feature

This commit is contained in:
Cutiful 2025-06-12 09:20:47 +07:00
parent 69b7cbac0f
commit 31c70e7a3f
17 changed files with 658 additions and 9 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 B

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1,61 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.di.cameraModule
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.di.diagnosisModule
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.navigation.NavGraph
import com.syaroful.agrilinkvocpro.ui.theme.AgrilinkVocproTheme
import org.koin.core.context.loadKoinModules
import org.koin.core.context.unloadKoinModules
class PlantDiseaseDetectionActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// load Module
loadKoinModules(listOf(cameraModule, diagnosisModule))
Log.d("KOIN_DEBUG", "✅ cameraModule loaded, yeay 🤗")
enableEdgeToEdge()
if (!hasRequiredPermissions()) {
ActivityCompat.requestPermissions(this, CAMERAX_PERMISSIONS, 0)
}
setContent {
AgrilinkVocproTheme {
val navController = rememberNavController()
NavGraph(navController)
}
}
}
override fun onDestroy() {
super.onDestroy()
unloadKoinModules(listOf(cameraModule, diagnosisModule))
Log.d("KOIN_DEBUG", "🧹 cameraModule unloaded")
}
private fun hasRequiredPermissions(): Boolean {
return CAMERAX_PERMISSIONS.all {
ContextCompat.checkSelfPermission(
applicationContext,
it
) == PackageManager.PERMISSION_GRANTED
}
}
companion object {
private val CAMERAX_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA
)
}
}

View File

@ -0,0 +1,15 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.core.extention
import android.graphics.Bitmap
import androidx.core.graphics.createBitmap
fun cropToSquare(bitmap: Bitmap?): Bitmap {
if (bitmap == null) {
return createBitmap(100, 100)
} else {
val dimension = minOf(bitmap.width, bitmap.height)
val xOffset = (bitmap.width - dimension) / 2
val yOffset = (bitmap.height - dimension) / 2
return Bitmap.createBitmap(bitmap, xOffset, yOffset, dimension, dimension)
}
}

View File

@ -0,0 +1,25 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model
import kotlinx.serialization.Serializable
@Serializable
data class GeminiRequest(
val contents: List<GeminiContent>
)
@Serializable
data class GeminiContent(
val parts: List<GeminiPart>
)
@Serializable
data class GeminiPart(
val text: String? = null,
val inline_data: InlineData? = null
)
@Serializable
data class InlineData(
val mime_type: String,
val data: String
)

View File

@ -0,0 +1,26 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model
import kotlinx.serialization.Serializable
@Serializable
data class GeminiResponse(
val candidates: List<Candidate>,
)
@Serializable
data class Candidate(
val avgLogprobs: Double,
val content: Content,
val finishReason: String
)
@Serializable
data class Content(
val parts: List<Part>,
val role: String
)
@Serializable
data class Part(
val text: String
)

View File

@ -0,0 +1,18 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model
import kotlinx.serialization.Serializable
@Serializable
data class PlantDiseaseDetectionResponse(
val cause: String? = null,
val description: String? = null,
val diagnosis: String? = null,
val prevention: List<String>? = null,
val treatment: List<Treatment>? = null
)
@Serializable
data class Treatment(
val description: String? = null,
val method: String? = null
)

View File

@ -0,0 +1,15 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.network
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.GeminiRequest
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.GeminiResponse
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Query
interface GeminiApiService {
@POST("v1beta/models/gemini-2.0-flash:generateContent")
suspend fun detectDisease(
@Query("key") apiKey: String,
@Body request: GeminiRequest
): GeminiResponse
}

View File

@ -0,0 +1,7 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.repository
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.PlantDiseaseDetectionResponse
interface PlantDiagnosisRepository {
suspend fun detectDisease(base64Image: String, prompt: String): PlantDiseaseDetectionResponse
}

View File

@ -0,0 +1,41 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.repository
import android.util.Log
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.GeminiContent
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.GeminiPart
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.GeminiRequest
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.InlineData
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.PlantDiseaseDetectionResponse
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.network.GeminiApiService
import kotlinx.serialization.json.Json
class PlantDiagnosisRepositoryImpl(private val api: GeminiApiService) : PlantDiagnosisRepository {
override suspend fun detectDisease(
base64Image: String,
prompt: String
): PlantDiseaseDetectionResponse {
val request = GeminiRequest(
contents = listOf(
GeminiContent(
parts = listOf(
GeminiPart(text = prompt),
GeminiPart(inline_data = InlineData("image/jpeg", base64Image))
)
)
)
)
val response = api.detectDisease(
apiKey = "AIzaSyDYZHlMfOdFAcTFptWHqhPwIO734VdNUWE", // Ideally use BuildConfig or secure storage
request = request
)
Log.d("PlantDiagnosisRepository", "Response: $response")
val rawJson = response.candidates.first().content.parts.first().text
val cleanedJson = rawJson
.replace("```json", "")
.replace("```", "")
.trim()
return Json.decodeFromString(cleanedJson)
}
}

View File

@ -8,5 +8,5 @@ import org.koin.dsl.module
val cameraModule = module { val cameraModule = module {
single<CameraRepository> { CameraRepositoryImpl() } single<CameraRepository> { CameraRepositoryImpl() }
viewModel { CameraViewModel(get(), get())} viewModel { CameraViewModel(get())}
} }

View File

@ -0,0 +1,47 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.network.GeminiApiService
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.repository.PlantDiagnosisRepository
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.repository.PlantDiagnosisRepositoryImpl
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.detail.PlantDiagnosisViewModel
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import retrofit2.Retrofit
@OptIn(ExperimentalSerializationApi::class)
val diagnosisModule = module {
single {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
}
single {
val contentType = "application/json".toMediaType()
val json = Json {
ignoreUnknownKeys = true
}
Retrofit.Builder()
.baseUrl("https://generativelanguage.googleapis.com/")
.client(get()) // ambil OkHttpClient dari Koin
.addConverterFactory(json.asConverterFactory(contentType))
.build()
}
single<GeminiApiService> {
get<Retrofit>().create(GeminiApiService::class.java)
}
single<PlantDiagnosisRepository> { PlantDiagnosisRepositoryImpl(get()) }
viewModel { PlantDiagnosisViewModel(get()) }
}

View File

@ -0,0 +1,28 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.camera.CameraScreen
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.camera.CameraViewModel
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.detail.DetailScreen
import org.koin.androidx.compose.koinViewModel
@Composable
fun NavGraph(navController: NavHostController) {
val cameraViewModel: CameraViewModel = koinViewModel()
NavHost(navController, startDestination = "camera_screen") {
composable("camera_screen") {
CameraScreen(navController, cameraViewModel)
}
composable("detail_screen"){
DetailScreen(
cameraViewModel = cameraViewModel,
onBack = { navController.popBackStack() }
)
}
}
}

View File

@ -24,7 +24,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -48,12 +47,7 @@ fun CameraScreen(
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
// val bitmaps by viewModel.bitmaps.collectAsState()
val bitmap by viewModel.bitmap.collectAsState()
val scope = rememberCoroutineScope()
// val scaffoldState = rememberBottomSheetScaffoldState()
val controller = remember { val controller = remember {
LifecycleCameraController(context).apply { LifecycleCameraController(context).apply {

View File

@ -16,7 +16,6 @@ private const val TAG = "CameraViewModel"
class CameraViewModel( class CameraViewModel(
private val cameraRepository: CameraRepository, private val cameraRepository: CameraRepository,
private val diagnosisViewModel: PlantDiagnosisViewModel
) : ViewModel() { ) : ViewModel() {
@ -26,7 +25,6 @@ class CameraViewModel(
private val _bitmap = MutableStateFlow<Bitmap?>(null) private val _bitmap = MutableStateFlow<Bitmap?>(null)
val bitmap: StateFlow<Bitmap?> = _bitmap val bitmap: StateFlow<Bitmap?> = _bitmap
val prompt = AppConstant().prompt
fun takePicture( fun takePicture(
controller: LifecycleCameraController, controller: LifecycleCameraController,

View File

@ -0,0 +1,87 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.camera.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.R
import com.syaroful.agrilinkvocpro.ui.theme.MainGreen
@Composable
fun CustomCameraShutter(
modifier: Modifier = Modifier,
onShutterClick: () -> Unit = {},
onFlashClick: () -> Unit = {},
onGalleryClick: () -> Unit = {}
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 44.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
// Tombol Flash
IconButton(
onClick = onFlashClick,
) {
Icon(
modifier = Modifier.size(32.dp),
painter = painterResource(id = R.drawable.flash),
contentDescription = "Flash",
tint = MaterialTheme.colorScheme.onBackground
)
}
IconButton(
onClick = onShutterClick,
modifier = Modifier
.size(72.dp)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.onBackground,
shape = CircleShape
)
.background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f), shape = CircleShape)
) {
Box(
modifier = Modifier
.size(56.dp)
.background(MainGreen, shape = CircleShape)
)
}
IconButton(
onClick = onGalleryClick,
) {
Icon(
modifier = Modifier.size(32.dp),
painter = painterResource(id = R.drawable.solar_gallery_broken),
contentDescription = "Gallery",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
@Preview
@Composable
fun CameraShutterPreview() {
CustomCameraShutter()
}

View File

@ -0,0 +1,253 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.detail
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.syaroful.agrilinkvocpro.R
import com.syaroful.agrilinkvocpro.core.components.DefaultErrorComponent
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.core.AppConstant
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.camera.CameraViewModel
import com.syaroful.agrilinkvocpro.ui.theme.MainGreen
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailScreen(
cameraViewModel: CameraViewModel,
onBack: () -> Unit = {}
) {
val diagnosisViewModel: PlantDiagnosisViewModel = koinViewModel()
val bitmap by cameraViewModel.bitmap.collectAsState()
val diagnosisState by diagnosisViewModel.state.collectAsState()
LaunchedEffect(bitmap) {
if (bitmap != null && diagnosisState == null) {
diagnosisViewModel.analyzePlant(bitmap = bitmap!!, prompt = AppConstant().prompt)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"Deteksi Penyakit Tanaman",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
.padding(innerPadding)
.padding(24.dp),
contentAlignment = Alignment.TopCenter
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(
10.dp,
Alignment.Top
)
) {
bitmap?.let {
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 180.dp),
shape = RoundedCornerShape(8.dp),
) {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Gambar Tanaman",
contentScale = ContentScale.Crop, // Make image fill the Card
)
}
}
when (val result = diagnosisState) {
null -> {
Box(
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
CircularProgressIndicator(color = MainGreen)
}
}
else -> {
if (result.isFailure) {
DefaultErrorComponent(
painter = painterResource(id = R.drawable.mascot_confused),
label = "Oops!",
message = result.exceptionOrNull()?.message.toString()
)
} else if (result.isSuccess) {
val data = result.getOrNull()
if (data?.diagnosis == "Sehat") {
Box(
modifier = Modifier
.background(
color = Color.Green,
shape = RoundedCornerShape(4.dp)
)
.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Text(
text = "Tanaman Sehat",
color = Color.White,
style = MaterialTheme.typography.labelMedium
)
}
} else if (data?.diagnosis != null) {
// Tampilkan detail diagnosis
Box(
modifier = Modifier
.background(
color = Color.Red,
shape = RoundedCornerShape(4.dp)
)
.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Text(
text = "Terkena Penyakit",
color = Color.White,
style = MaterialTheme.typography.labelMedium
)
}
Text(
text = "Penyakit Terdeteksi :",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF3D3D)
)
Text(
data.diagnosis ?: "Unknown",
style = MaterialTheme.typography.headlineSmall
)
Text(
text = "Penyebab :",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF3D3D)
)
Text(
data.cause ?: "Unknown",
style = MaterialTheme.typography.titleSmall
)
TextCardComponent(
label = "Informasi Umum",
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = data.description ?: "Unknown",
style = MaterialTheme.typography.bodyMedium
)
}
TextCardComponent(
label = "Pengobatan",
) {
data.treatment?.forEach {
Column {
Text(
it.method ?: "Unknown",
style = MaterialTheme.typography.labelLarge
)
Text(
it.description ?: "Unknown",
style = MaterialTheme.typography.bodySmall
)
HorizontalDivider()
}
}
}
TextCardComponent(
label = "Pencegahan",
) {
data.prevention?.forEach {
Column {
Text(
it,
style = MaterialTheme.typography.bodySmall
)
HorizontalDivider()
}
}
}
} else {
Text("Gagal parsing response")
}
}
}
}
}
}
}
}
@Composable
fun TextCardComponent(
label: String,
content: @Composable () -> Unit
) {
Column(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(8.dp)
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(
10.dp,
Alignment.Top
),
horizontalAlignment = Alignment.Start,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = label,
style = MaterialTheme.typography.titleMedium
)
content()
}
}

View File

@ -0,0 +1,34 @@
package com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.detail
import android.graphics.Bitmap
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.core.extention.toBase64
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.model.PlantDiseaseDetectionResponse
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.repository.PlantDiagnosisRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class PlantDiagnosisViewModel(
private val repository: PlantDiagnosisRepository
) : ViewModel() {
private val _state = MutableStateFlow<Result<PlantDiseaseDetectionResponse>?>(null)
val state: StateFlow<Result<PlantDiseaseDetectionResponse>?> = _state
fun analyzePlant(bitmap: Bitmap, prompt: String) {
Log.d("PlantDiagnosisViewModel", "analyzePlant() called")
viewModelScope.launch {
try {
val base64 = bitmap.toBase64()
val result = repository.detectDisease(prompt = prompt, base64Image = base64)
_state.value = Result.success(result)
Log.d("PlantDiagnosisViewModel", "Diagnosis result: $result")
} catch (e: Exception) {
_state.value = Result.failure(e)
Log.e("PlantDiagnosisViewModel", "Error during diagnosis", e)
}
}
}
}