refactor: fix some feature

This commit is contained in:
Cutiful 2025-07-30 13:56:29 +07:00
parent ac5f7c1619
commit faa8f2ea76
35 changed files with 1115 additions and 399 deletions

View File

@ -13,7 +13,7 @@ android {
applicationId = "com.syaroful.agrilinkvocpro"
minSdk = 29
targetSdk = 35
versionCode = 13
versionCode = 15
versionName = "1.0.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@ -23,8 +23,8 @@ fun DownloadModuleConfirmationDialog(
val message = when (moduleName) {
"control_feature" -> "Apakah Anda ingin mendownload fitur Kontrol Aktuator?"
"recipe_feature" -> "Apakah Anda ingin mendownload fitur Resep Pertumbuhan?"
"price_prediction_feature" -> "Apakah Anda ingin mendownload fitur Prediksi Harga Komoditas?"
"growth_recipe_feature" -> "Apakah Anda ingin mendownload fitur Formula Pertumbuhan Optimal?"
"commodity_price_prediction_feature" -> "Apakah Anda ingin mendownload fitur Prediksi Harga Komoditas?"
"plant_disease_detection_feature" -> "Apakah Anda ingin mendownload fitur Deteksi Penyakit Tanaman?"
else -> "Apakah Anda ingin mendownload modul ini?"
}
@ -69,7 +69,7 @@ fun DownloadModuleConfirmationDialog(
fun PreviewDownloadModuleConfirmationDialog() {
DownloadModuleConfirmationDialog(
moduleName = "price_prediction_feature",
moduleName = "commodity_price_prediction_feature",
onClickConfirm = {},
onDismiss = {}
)

View File

@ -0,0 +1,32 @@
package com.syaroful.agrilinkvocpro.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BedLocationResponse(
@SerialName("data")
val `data`: BedLocationInfo?,
@SerialName("message")
val message: String?,
@SerialName("statusCode")
val statusCode: Int?
)
@Serializable
data class BedLocationInfo(
@SerialName("address")
val address: String?,
@SerialName("createdAt")
val createdAt: String?,
@SerialName("deletedAt")
val deletedAt: String?,
@SerialName("description")
val description: String?,
@SerialName("id")
val id: Int?,
@SerialName("name")
val name: String?,
@SerialName("updatedAt")
val updatedAt: String?
)

View File

@ -0,0 +1,36 @@
package com.syaroful.agrilinkvocpro.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SensorInformationResponse(
@SerialName("data")
val `data`: SensorInformation?,
@SerialName("message")
val message: String?,
@SerialName("statusCode")
val statusCode: Int?
)
@Serializable
data class SensorInformation(
@SerialName("bedLocationId")
val bedLocationId: Int?,
@SerialName("createdAt")
val createdAt: String?,
@SerialName("deletedAt")
val deletedAt: String?,
@SerialName("desc")
val desc: String?,
@SerialName("id")
val id: Int?,
@SerialName("name")
val name: String?,
@SerialName("publicName")
val publicName: String?,
@SerialName("sensorTypeId")
val sensorTypeId: Int?,
@SerialName("updatedAt")
val updatedAt: String?
)

View File

@ -1,15 +1,18 @@
package com.syaroful.agrilinkvocpro.data.network
import com.syaroful.agrilinkvocpro.data.model.BedLocationResponse
import com.syaroful.agrilinkvocpro.data.model.DhtGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.LoginResponse
import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.RegisterResponse
import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorInformationResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
@ -61,4 +64,16 @@ interface ApiService {
@Query("sensor") sensor: String
): Response<DhtGraphicDataResponse>
@GET("api/sensors/{sensorId}")
suspend fun getSensorInformation(
@Header("Authorization") authHeader: String,
@Path("sensorId") sensorId: Int
): Response<SensorInformationResponse>
@GET("api/bed-locations/{bedId}")
suspend fun getBedLocation(
@Header("Authorization") authHeader: String,
@Path("bedId") bedId: Int
): Response<BedLocationResponse>
}

View File

@ -1,8 +1,10 @@
package com.syaroful.agrilinkvocpro.data.repository
import com.syaroful.agrilinkvocpro.data.model.BedLocationResponse
import com.syaroful.agrilinkvocpro.data.model.DhtGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorInformationResponse
import com.syaroful.agrilinkvocpro.data.network.ApiService
import retrofit2.Response
@ -38,4 +40,19 @@ class SensorDataRepository(private val apiService: ApiService) {
): Response<DhtGraphicDataResponse> {
return apiService.getDhtDataSensor(authHeader, startDate, endDate, timeRange, sensor)
}
suspend fun getSensorInformation(
authHeader: String,
sensorId: Int
): Response<SensorInformationResponse>{
return apiService.getSensorInformation(authHeader, sensorId = sensorId)
}
suspend fun getBedLocation(
authHeader: String,
bedId: Int
): Response<BedLocationResponse>{
return apiService.getBedLocation(authHeader, bedId = bedId)
}
}

View File

@ -1,9 +1,16 @@
package com.syaroful.agrilinkvocpro.presentation.screen.detail
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@ -17,6 +24,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.syaroful.agrilinkvocpro.core.placeholder.shimmerEffect
import com.syaroful.agrilinkvocpro.core.utils.ResultState
import com.syaroful.agrilinkvocpro.core.utils.extention.getCurrentDate
import com.syaroful.agrilinkvocpro.core.utils.extention.toFormattedString
import com.syaroful.agrilinkvocpro.presentation.screen.detail.component.DetailDhtContent
@ -33,23 +44,27 @@ fun DetailScreen(
detailViewModel: DetailViewModel = koinViewModel(),
) {
val date = remember { mutableStateOf(Date().toFormattedString()) }
val id = if (sensorId == "dht") 1 else if (sensorId == "npk1") 2 else 3
LaunchedEffect(sensorId) {
if (sensorId == "dht") {
detailViewModel.fetchDhtData(date = date.value, sensor = sensorId)
detailViewModel.fetchSensorInformation(id)
} else {
detailViewModel.fetchNpkData(date = date.value, sensor = sensorId)
detailViewModel.fetchSensorInformation(id)
}
}
val npkDataState by detailViewModel.npkDataState.collectAsState()
val dhtDataState by detailViewModel.dhtDataState.collectAsState()
val bedState by detailViewModel.bedState.collectAsState()
val currentData = detailViewModel.currentSensorData
val isRefreshing = remember { mutableStateOf(false) }
val currentDate = remember { mutableStateOf(getCurrentDate()) }
Scaffold(
topBar = { DetailTopBar() }) { innerPadding ->
PullToRefreshBox(
@ -61,26 +76,72 @@ fun DetailScreen(
detailViewModel.fetchNpkData(date = date.value, sensor = sensorId)
}
}) {
if (sensorId == "dht") DetailDhtContent(
modifier = Modifier.padding(innerPadding),
viewModel = detailViewModel,
dhtDataState = dhtDataState,
currentData = currentData,
sensorId = sensorId,
isRefreshing = isRefreshing,
date = date,
currentDate = currentDate
)
else DetailNpkContent(
modifier = Modifier.padding(innerPadding),
viewModel = detailViewModel,
npkDataState = npkDataState,
currentData = currentData,
sensorId = sensorId,
isRefreshing = isRefreshing,
date = date,
currentDate = currentDate
)
Column(
modifier = modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
when (bedState) {
is ResultState.Success -> {
val data = (bedState as ResultState.Success).data
ListItem(
modifier = Modifier
.padding(horizontal = 16.dp)
.border(
shape = RoundedCornerShape(8.dp),
width = 1.dp,
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(
alpha = 0.5f
)
),
headlineContent = {
Text(text = (data?.data?.address ?: "unknown"), style = MaterialTheme.typography.titleSmall)
},
supportingContent = {
Text(text = (data?.data?.description ?: "unknown"), style = MaterialTheme.typography.bodySmall, modifier = Modifier.alpha(0.6f))
},
trailingContent = {
Box(
modifier = Modifier.height(48.dp).padding(vertical = 16.dp),
){ Text(text = (data?.data?.name ?: "unknown")) }
}
)
}
is ResultState.Error -> {
val errorMessage = (bedState as ResultState.Error).message
Text(errorMessage)
}
ResultState.Idle -> {}
ResultState.Loading -> Box(
modifier
.fillMaxWidth()
.height(80.dp)
.shimmerEffect()
)
}
if (sensorId == "dht") DetailDhtContent(
viewModel = detailViewModel,
dhtDataState = dhtDataState,
currentData = currentData,
sensorId = sensorId,
isRefreshing = isRefreshing,
date = date,
currentDate = currentDate
)
else DetailNpkContent(
viewModel = detailViewModel,
npkDataState = npkDataState,
currentData = currentData,
sensorId = sensorId,
isRefreshing = isRefreshing,
date = date,
currentDate = currentDate
)
}
}
}
}

View File

@ -7,9 +7,11 @@ import com.syaroful.agrilinkvocpro.core.utils.ResultState
import com.syaroful.agrilinkvocpro.core.utils.extention.mapToUserFriendlyError
import com.syaroful.agrilinkvocpro.core.utils.extention.toFormattedString
import com.syaroful.agrilinkvocpro.data.UserPreferences
import com.syaroful.agrilinkvocpro.data.model.BedLocationResponse
import com.syaroful.agrilinkvocpro.data.model.DhtGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorInformationResponse
import com.syaroful.agrilinkvocpro.data.repository.SensorDataRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@ -30,14 +32,75 @@ class DetailViewModel(
val currentSensorData: SensorDataResponse?
get() = sensorDataRepository.latestSensorData
private val _npkDataState = MutableStateFlow<ResultState<NpkGraphicDataResponse>>(ResultState.Idle)
private val _npkDataState =
MutableStateFlow<ResultState<NpkGraphicDataResponse>>(ResultState.Idle)
val npkDataState: StateFlow<ResultState<NpkGraphicDataResponse>> = _npkDataState.asStateFlow()
private val _dhtDataState = MutableStateFlow<ResultState<DhtGraphicDataResponse>>(ResultState.Idle)
private val _dhtDataState =
MutableStateFlow<ResultState<DhtGraphicDataResponse>>(ResultState.Idle)
val dhtDataState: StateFlow<ResultState<DhtGraphicDataResponse>> = _dhtDataState.asStateFlow()
private val _sensorInformationState =
MutableStateFlow<ResultState<SensorInformationResponse>>(ResultState.Idle)
val sensorInformationState: StateFlow<ResultState<SensorInformationResponse>> =
_sensorInformationState.asStateFlow()
private val _bedState =
MutableStateFlow<ResultState<BedLocationResponse>>(ResultState.Idle)
val bedState: StateFlow<ResultState<BedLocationResponse>> =
_bedState.asStateFlow()
private val today = Date()
fun fetchSensorInformation(sensorId: Int) {
_bedState.value = ResultState.Loading
Log.d(TAG, "Sensor ID: $sensorId")
viewModelScope.launch {
val token = userPreferences.tokenFlow.first()
val authHeader = "Bearer $token"
try {
delay(300L)
val response = sensorDataRepository.getSensorInformation(
authHeader = authHeader,
sensorId = sensorId
)
if (response.isSuccessful) {
Log.d(TAG, "Sensor Info: ${response.body()}")
response.body()?.let { body ->
val responseBed = sensorDataRepository.getBedLocation(
authHeader,
body.data?.bedLocationId ?: 1
)
if (responseBed.isSuccessful) {
Log.d(TAG, "Bed Location: ${responseBed.body()}")
responseBed.body()?.let { bedBody ->
_bedState.value = ResultState.Success(bedBody)
} ?: run {
_bedState.value =
ResultState.Error("Informasi Bed tidak ditemukan")
}
} else {
_bedState.value =
ResultState.Error("Error: ${responseBed.code()} - ${responseBed.message()}")
}
} ?: run {
_sensorInformationState.value =
ResultState.Error("Informasi Sensor tidak ditemukan")
}
} else {
_sensorInformationState.value =
ResultState.Error("Error: ${response.code()} - ${response.message()}")
}
} catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e)
_sensorInformationState.value = ResultState.Error(errorMessage)
_bedState.value = ResultState.Error(errorMessage)
Log.d(TAG, "Failed to fetch data: ${e.message}")
}
}
}
fun fetchNpkData(
date: String = today.toFormattedString(),
sensor: String,
@ -62,7 +125,8 @@ class DetailViewModel(
_npkDataState.value = ResultState.Error("Data tidak ditemukan")
}
} else {
_npkDataState.value = ResultState.Error("Error: ${response.code()} - ${response.message()}")
_npkDataState.value =
ResultState.Error("Error: ${response.code()} - ${response.message()}")
}
} catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e)
@ -96,7 +160,8 @@ class DetailViewModel(
_dhtDataState.value = ResultState.Error("Data tidak ditemukan")
}
} else {
_dhtDataState.value = ResultState.Error("Error: ${response.code()} - ${response.message()}")
_dhtDataState.value =
ResultState.Error("Error: ${response.code()} - ${response.message()}")
}
} catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e)

View File

@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -54,7 +52,6 @@ fun DetailDhtContent(
Column(
modifier = modifier
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
when (dhtDataState) {
is ResultState.Loading -> {

View File

@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -57,7 +55,6 @@ fun DetailNpkContent(
Column(
modifier = modifier
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
when (npkDataState) {
is ResultState.Loading -> {

View File

@ -4,8 +4,10 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
@ -53,6 +55,7 @@ fun DynamicBottomSheet(
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f),
contentDescription = "Dropdown Arrow"
)
}
@ -62,18 +65,23 @@ fun DynamicBottomSheet(
onDismissRequest = { setShowSheet(false) },
sheetState = sheetState
) {
options.forEach { option ->
Text(
text = option,
modifier = Modifier
.fillMaxWidth()
.clickable {
onValueSelected(option)
setSelectedOption(option)
setShowSheet(false)
}
.padding(16.dp)
)
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(options.size) { index ->
val option = options[index]
Text(
text = option,
modifier = Modifier
.fillMaxWidth()
.clickable {
onValueSelected(option)
setSelectedOption(option)
setShowSheet(false)
}
.padding(16.dp)
)
}
}
}
}

View File

@ -324,22 +324,22 @@ fun DynamicFeatureSection(
horizontalArrangement = Arrangement.SpaceBetween
) {
MenuItemButton(
label = "Kontrol\nAktuator",
label = "Control\nActuator",
icon = painterResource(id = R.drawable.control_actuator_icon),
onClick = { onFeatureClick("control_feature") },
)
MenuItemButton(
label = "Resep\nPertumbuhan",
label = "Growth\nFormula",
icon = painterResource(id = R.drawable.growth_recipe_icon),
onClick = { onFeatureClick("growth_recipe_feature") },
)
MenuItemButton(
label = "Harga\nKomoditas",
label = "Commodity\nPrice",
icon = painterResource(id = R.drawable.commodity_price_prediction_icon),
onClick = { onFeatureClick("commodity_price_prediction_feature") },
)
MenuItemButton(
label = "Deteksi\nPenyakit",
label = "Disease\nDetection",
icon = painterResource(id = R.drawable.plant_disease_detection_icon),
onClick = { onFeatureClick("plant_disease_detection_feature") },
)
@ -378,7 +378,7 @@ fun GreenHouseInformationSection(navController: NavController) {
modifier = Modifier.weight(1f)
) {
Text(text = "2 Komoditas", color = MainGreen, style = textTheme.bodyMedium)
Text(text = "2 Commodities", color = MainGreen, style = textTheme.bodyMedium)
Spacer(modifier = Modifier.height(24.dp))
Text(text = "Green House Bumiaji", style = textTheme.bodyLarge)
Text(
@ -398,14 +398,3 @@ fun GreenHouseInformationSection(navController: NavController) {
)
}
}
//@Preview(showBackground = true, name = "Light Mode")
//@Preview(showBackground = true, name = "Dark Mode", uiMode = UI_MODE_NIGHT_YES)
//@Composable
//fun HomePreview() {
// val navController = rememberNavController()
// HomeScreen(
// navController = navController,
// onFeatureClick = {},
// )
//}

View File

@ -24,9 +24,6 @@ class HomeViewModel(
private val _homeState = MutableStateFlow<ResultState<SensorDataResponse>>(ResultState.Idle)
val homeState: StateFlow<ResultState<SensorDataResponse>> = _homeState
// var currentDataSensor: SensorDataResponse? = null
// private set
init {
getGreenHouseData()
}

View File

@ -94,11 +94,11 @@ fun RegisterScreen(
)
Text(
modifier = Modifier.fillMaxWidth(),
text = "Login", style = textTheme.titleMedium, textAlign = TextAlign.Center
text = "Register", style = textTheme.titleMedium, textAlign = TextAlign.Center
)
Text(
modifier = Modifier.fillMaxWidth(),
text = "Halo! yuk masuk ke dalam akunmu",
text = "Halo! yuk daftarkan akun ke aplikasi",
style = textTheme.titleSmall.copy(color = DarkGrey),
textAlign = TextAlign.Center
)

View File

@ -7,7 +7,7 @@
<string name="control_feature_label">Kontrol Aktuator</string>
<string name="play_store_icon_desc">Google Play Store</string>
<string name="download_module_title">Unduh Modul Fitur Dinamis</string>
<string name="download_module_title">Unduh Fitur</string>
<string name="download_module_message">anda perlu mengunduh modul fitur dinamis agar fitur ini dapat digunakan</string>
<string name="download">Download</string>
<string name="cancel">Cancel</string>

View File

@ -4,17 +4,31 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.prediction.PricePredictionScreen
import androidx.navigation.compose.rememberNavController
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.di.appModule
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.di.networkModule
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.di.viewModelModule
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.navigation.SetupNavigation
import com.syaroful.agrilinkvocpro.presentation.theme.AgrilinkVocproTheme
import org.koin.core.context.loadKoinModules
import org.koin.core.context.unloadKoinModules
class PricePredictionActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
loadKoinModules(listOf(appModule, viewModelModule, networkModule))
setContent {
AgrilinkVocproTheme {
PricePredictionScreen()
val navController = rememberNavController()
SetupNavigation(navController)
}
}
}
override fun onDestroy() {
super.onDestroy()
unloadKoinModules(listOf(appModule, viewModelModule, networkModule))
}
}

View File

@ -0,0 +1,64 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.constant
class AppConstant {
val locationOption: List<String> = listOf(
"Kota Batu",
"Kota Blitar",
"Kota Kediri",
"Kota Madiun",
"Kota Malang",
"Kota Mojokerto",
"Kota Pasuruan",
"Kota Probolinggo",
"Kota Surabaya",
"Kabupaten Bangkalan",
"Kabupaten Banyuwangi",
"Kabupaten Blitar",
"Kabupaten Bojonegoro",
"Kabupaten Bondowoso",
"Kabupaten Gresik",
"Kabupaten Jember",
"Kabupaten Jombang",
"Kabupaten Kediri",
"Kabupaten Lamongan",
"Kabupaten Lumajang",
"Kabupaten Madiun",
"Kabupaten Magetan",
"Kabupaten Malang",
"Kabupaten Mojokerto",
"Kabupaten Nganjuk",
"Kabupaten Ngawi",
"Kabupaten Pacitan",
"Kabupaten Pamekasan",
"Kabupaten Pasuruan",
"Kabupaten Ponorogo",
"Kabupaten Probolinggo",
"Kabupaten Sampang",
"Kabupaten Sidoarjo",
"Kabupaten Situbondo",
"Kabupaten Sumenep",
"Kabupaten Trenggalek",
"Kabupaten Tuban",
"Kabupaten Tulungagung",
)
val commodityOptions: List<String> = listOf(
"Beras Premium",
"Beras Medium",
"Bawang Merah",
"Bawang Putih Sinco/Honan",
"BUNCIS",
"Cabe Merah Besar",
"Cabe Merah Keriting",
"Cabe Rawit Merah",
"Daging Ayam Kampung",
"Daging Ayam Ras",
"Daging Sapi Paha Belakang",
"Telur Ayam Kampung",
"Telur Ayam Ras",
"Tomat Merah",
"KENTANG",
"KOL/KUBIS",
"WORTEL",
)
}

View File

@ -0,0 +1,233 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.syaroful.agrilinkvocpro.R
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.constant.AppConstant
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity.component.CommodityListItem
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity.component.HeaderSection
import com.syaroful.agrilinkvocpro.core.components.DefaultErrorComponent
import com.syaroful.agrilinkvocpro.core.components.Loader
import com.syaroful.agrilinkvocpro.core.utils.ResultState
import com.syaroful.agrilinkvocpro.core.utils.extention.toFormattedString
import org.koin.androidx.compose.koinViewModel
import java.util.Calendar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommodityPriceScreen(
navController: NavHostController,
viewModel: CommodityViewModel = koinViewModel()
) {
val state by viewModel.state.collectAsState()
val commodityOptions = AppConstant().commodityOptions
val locationOptions = AppConstant().locationOption
val selectedCommodity = remember { mutableStateOf(commodityOptions[0]) }
val selectedLocation = remember { mutableStateOf(locationOptions[0]) }
val calendar = remember { Calendar.getInstance().apply { add(Calendar.DATE, -2) } }
val dateYesterday = calendar.time.toFormattedString()
val isRefreshing = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (state == ResultState.Idle) {
viewModel.loadCommodityPrice(
commodityName = selectedCommodity.value,
market = "",
date = dateYesterday,
city = selectedLocation.value
)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"Commodity Price",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
)
}
) { innerPadding ->
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
viewModel.loadCommodityPrice(
commodityName = selectedCommodity.value,
market = "",
date = dateYesterday,
city = selectedLocation.value
)
}
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(innerPadding)
.padding(16.dp)
) {
item {
HeaderSection(
navController = navController,
commodityOptions = commodityOptions,
locationOptions = locationOptions,
selectedCommodity = selectedCommodity,
selectedLocation = selectedLocation,
viewModel = viewModel,
dateYesterday = dateYesterday
)
}
when (state) {
is ResultState.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Loader()
}
}
}
is ResultState.Success<*> -> {
val result = state as ResultState.Success
val data = result.data ?: emptyList()
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Data Komoditas", style = MaterialTheme.typography.titleSmall)
Text(dateYesterday, style = MaterialTheme.typography.titleSmall)
}
}
if (data.isEmpty()) {
item {
DefaultErrorComponent(
modifier = Modifier.padding(top = 16.dp),
label = "Waduh!",
message = "Belum ada data yang tersedia",
painter = painterResource(id = R.drawable.mascot_confused)
)
}
} else {
items(data) {
CommodityListItem(it)
Spacer(modifier = Modifier.height(4.dp))
}
}
}
is ResultState.Error -> {
item {
DefaultErrorComponent(
label = "Oops!",
message = (state as ResultState.Error).message
)
}
}
ResultState.Idle -> {}
}
}
}
}
}
// Column(
// modifier = Modifier
// .padding(16.dp)
// .fillMaxWidth()
// .background(
// color = MaterialTheme.colorScheme.surfaceContainerHigh,
// shape = RoundedCornerShape(12.dp)
// )
// .padding(16.dp),
// verticalArrangement = Arrangement.spacedBy(12.dp)
// ) {
// Text(
// text = "Harga Hari Ini",
// style = MaterialTheme.typography.labelMedium,
// modifier = Modifier.alpha(0.5f)
// )
// Row(
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Text(
// text = "Rp 30.000 ",
// style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.W400)
// )
// Text(
// text = "↑ 2.5%",
// style = MaterialTheme.typography.bodyLarge,
// color = MainGreen
// )
// }
// Text(
// text = "Prediksi Besok",
// style = MaterialTheme.typography.labelMedium,
// modifier = Modifier.alpha(0.5f)
// )
// Row(
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Text(
// text = "Rp 28.000 ",
// style = MaterialTheme.typography.headlineMedium,
// color = MainGreen
// )
// Text(
// text = "↓ 7,6%",
// style = MaterialTheme.typography.bodyLarge,
// color = Color.Red
// )
// }
// PriceChart(
// modifier = Modifier
// .fillMaxWidth()
// .height(140.dp),
// day = listOf(1, 2, 3, 4, 5, 6, 7),
// values = listOf(5000.0, 6000.0, 4000.0, 7000.0, 8500.0, 5450.0, 6400.0),
// )
// }

View File

@ -0,0 +1,121 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity.component
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.component.FeatureBanner
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity.CommodityViewModel
import com.syaroful.agrilinkvocpro.presentation.screen.detail.component.DynamicBottomSheet
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
@Composable
fun HeaderSection(
navController: NavHostController,
commodityOptions: List<String>,
locationOptions: List<String>,
selectedCommodity: MutableState<String>,
selectedLocation: MutableState<String>,
viewModel: CommodityViewModel,
dateYesterday: String,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(12.dp)
)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
FeatureBanner()
PricePredictionButton { navController.navigate("prediction_screen") }
Text("Pilih Komoditas", style = MaterialTheme.typography.labelLarge)
DynamicBottomSheet(
width = 1f,
options = commodityOptions,
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(8.dp)
)
) {
selectedCommodity.value = it
viewModel.loadCommodityPrice(
commodityName = it,
market = "",
date = dateYesterday,
city = selectedLocation.value
)
}
Text("Pilih Lokasi", style = MaterialTheme.typography.labelLarge)
DynamicBottomSheet(
width = 1f,
options = locationOptions,
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(8.dp)
)
) {
selectedLocation.value = it
viewModel.loadCommodityPrice(
commodityName = selectedCommodity.value,
market = "",
date = dateYesterday,
city = it
)
}
}
}
@Composable
private fun PricePredictionButton(onClick: () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = MainGreen
),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(color = MainGreen, width = 1.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("✨ Lihat Prediksi Harga")
Image(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null
)
}
}
}

View File

@ -138,7 +138,7 @@ fun PriceChart(
"Harga",
spacing / 2f,
labelPadding - 100,
textPaint.apply { color = android.graphics.Color.GREEN }
textPaint
)
horizontalValue?.let { value ->

View File

@ -1,153 +0,0 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.prediction
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.syaroful.agrilinkvocpro.presentation.screen.detail.component.DynamicBottomSheet
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PricePredictionScreen() {
val commodityOptions = listOf("Labu Kabocha", "Melon", "Strawberry", "kentang", "Bayam")
val locationOptions = listOf("Malang", "Jombang", "Surabaya")
Scaffold(
topBar = {
TopAppBar(
title = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Prediksi Harga Komoditas",
style = MaterialTheme.typography.titleMedium
)
}
},
navigationIcon = {
IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding)
) {
Column(
modifier = Modifier
.padding(16.dp)
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(text = "Pilih Komoditas", style = MaterialTheme.typography.labelLarge)
DynamicBottomSheet(
width = 1f,
options = commodityOptions,
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(8.dp)
)
) {
}
Text(text = "Pilih Lokasi", style = MaterialTheme.typography.labelLarge)
DynamicBottomSheet(
width = 1f,
options = locationOptions,
modifier = Modifier.background(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = RoundedCornerShape(8.dp)
)
) {
}
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Harga Hari Ini",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.alpha(0.5f)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Rp 30.000 ",
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.W400)
)
Text(
text = "↑ 2.5%",
style = MaterialTheme.typography.bodyLarge,
color = MainGreen
)
}
Text(
text = "Prediksi Besok",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.alpha(0.5f)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Rp 28.000 ",
style = MaterialTheme.typography.headlineMedium,
color = MainGreen
)
Text(
text = "↓ 7,6%",
style = MaterialTheme.typography.bodyLarge,
color = Color.Red
)
}
PriceChart(
modifier = Modifier
.fillMaxWidth()
.height(140.dp),
day = listOf(1, 2, 3, 4, 5, 6, 7),
values = listOf(5000.0, 6000.0, 4000.0, 7000.0, 8500.0, 5450.0, 6400.0),
)
}
}
}
}

View File

@ -1,13 +1,69 @@
package com.syaroful.agrilinkvocpro.control_feature.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ActuatorHistoryResponse(
val `data`: List<ActuatorLog?>?,
@SerialName("data")
val `data`: List<ActuatorHistoryData?>?,
@SerialName("lastPage")
val lastPage: Int?,
@SerialName("message")
val message: String?,
@SerialName("nextPage")
val nextPage: Any?,
@SerialName("page")
val page: Int?,
@SerialName("perPage")
val perPage: Int?,
@SerialName("previousPage")
val previousPage: Any?,
@SerialName("statusCode")
val statusCode: Int?,
@SerialName("total")
val total: Int?
)
@Serializable
data class ActuatorHistoryData(
@SerialName("action")
val action: String?,
@SerialName("actuator")
val actuator: Actuator?,
@SerialName("actuatorId")
val actuatorId: Int?,
@SerialName("createdAt")
val createdAt: String?,
@SerialName("id")
val id: Int?,
@SerialName("triggeredBy")
val triggeredBy: String?,
@SerialName("turnOffAt")
val turnOffAt: Any?
)
@Serializable
data class Actuator(
@SerialName("actuatorTypeId")
val actuatorTypeId: Int?,
@SerialName("bedLocationId")
val bedLocationId: Any?,
@SerialName("createdAt")
val createdAt: String?,
@SerialName("deletedAt")
val deletedAt: Any?,
@SerialName("id")
val id: Int?,
@SerialName("maxDuration")
val maxDuration: Int?,
@SerialName("name")
val name: String?,
@SerialName("relayPin")
val relayPin: Int?,
@SerialName("slug")
val slug: String?,
@SerialName("updatedAt")
val updatedAt: String?
)

View File

@ -10,6 +10,7 @@ import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Query
interface ControlService {
// get all actuator status
@ -43,8 +44,17 @@ interface ControlService {
): Response<ControlLogResponse>
// get controls log
@GET("api//actuator-control-logs")
@GET("api/actuator-control-logs")
suspend fun getActuatorsControlLog(
@Header("Authorization") authHeader: String,
@Query("actuatorId") actuatorId: Int? = null,
@Query("status") status: String? = null,
@Query("startDate") startDate: String? = null,
@Query("endDate") endDate: String? = null,
@Query("page") page: Int? = null,
@Query("limit") limit: Int? = null,
@Query("sort_field") sortField: String? = null,
@Query("sort_direction") sortDirection: String? = null
): Response<ActuatorHistoryResponse>
}

View File

@ -37,8 +37,29 @@ class ControlRepository(
return controlService.getActuatorStatus(authHeader)
}
suspend fun getActuatorHistory(authHeader: String): Response<ActuatorHistoryResponse> {
return controlService.getActuatorsControlLog(authHeader)
suspend fun getActuatorHistory(
authHeader: String,
actuatorId: Int? = null,
status: String? = null,
startDate: String? = null,
endDate: String? = null,
page: Int? = null,
limit: Int? = null,
sortField: String? = null,
sortDirection: String? = null
): Response<ActuatorHistoryResponse> {
return controlService.getActuatorsControlLog(
authHeader = authHeader,
actuatorId = actuatorId,
status = status,
startDate = startDate,
endDate = endDate,
page = page,
limit = limit,
sortField = sortField,
sortDirection = sortDirection
)
}
}

View File

@ -1,75 +0,0 @@
//package com.syaroful.agrilinkvocpro.control_feature.page
//
//import android.content.res.Configuration.UI_MODE_NIGHT_YES
//import androidx.compose.foundation.layout.Arrangement
//import androidx.compose.foundation.layout.Column
//import androidx.compose.foundation.layout.Spacer
//import androidx.compose.foundation.layout.fillMaxSize
//import androidx.compose.foundation.layout.height
//import androidx.compose.foundation.layout.padding
//import androidx.compose.material3.MaterialTheme
//import androidx.compose.material3.Scaffold
//import androidx.compose.material3.Switch
//import androidx.compose.material3.Text
//import androidx.compose.runtime.Composable
//import androidx.compose.runtime.collectAsState
//import androidx.compose.runtime.getValue
//import androidx.compose.ui.Alignment
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.tooling.preview.Preview
//import androidx.compose.ui.unit.dp
//import androidx.lifecycle.viewmodel.compose.viewModel
//import com.syaroful.agrilinkvocpro.control_feature.presentation.control.ControlViewModel
//
//@Composable
//fun ControlScreen(
// modifier: Modifier = Modifier,
// relayState: Boolean,
// onRelayStateChange: (Boolean) -> Unit
//) {
// Scaffold { innerPadding ->
// Column(
// modifier = Modifier
// .fillMaxSize()
// .padding(innerPadding)
// .padding(24.dp),
// verticalArrangement = Arrangement.Center,
// horizontalAlignment = Alignment.CenterHorizontally
// ) {
// Text(text = "Kontrol Relay", style = MaterialTheme.typography.headlineMedium)
//
// Spacer(modifier = Modifier.height(24.dp))
//
// Switch(
// checked = relayState,
// onCheckedChange = { isChecked ->
// onRelayStateChange(isChecked)
// }
// )
// }
// }
//}
//
//@Composable
//fun ControlScreenRoute(
// modifier: Modifier = Modifier,
// viewModel: ControlViewModel = viewModel()
//) {
// val relayState by viewModel.relayState.collectAsState()
//
// ControlScreen(
// modifier = modifier,
// relayState = relayState,
// onRelayStateChange = { viewModel.setRelayState(it) }
// )
//}
//
//@Preview(showBackground = true, name = "Light Mode")
//@Preview(showBackground = true, name = "Dark Mode", uiMode = UI_MODE_NIGHT_YES)
//@Composable
//fun ControlScreenPreview() {
// ControlScreen(
// relayState = false,
// onRelayStateChange = {}
// )
//}

View File

@ -1,21 +1,24 @@
package com.syaroful.agrilinkvocpro.control_feature.presentation.control
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@ -38,9 +41,11 @@ import com.syaroful.agrilinkvocpro.control_feature.R
import com.syaroful.agrilinkvocpro.control_feature.core.components.ControlCard
import com.syaroful.agrilinkvocpro.control_feature.core.components.CustomSnackBar
import com.syaroful.agrilinkvocpro.control_feature.core.components.DisableControlCard
import com.syaroful.agrilinkvocpro.control_feature.core.components.FeatureBanner
import com.syaroful.agrilinkvocpro.control_feature.core.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorType
import com.syaroful.agrilinkvocpro.core.placeholder.shimmerEffect
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class)
@ -88,14 +93,6 @@ fun ControlActuatorScreen(
Text("Control Actuator", style = MaterialTheme.typography.titleMedium)
}
},
actions = {
IconButton(onClick = {
navController.navigate("control_history")
}) {
Icon(Icons.Filled.Refresh, contentDescription = "History")
}
}
)
}
) { innerPadding ->
@ -115,9 +112,11 @@ fun ControlActuatorScreen(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
FeatureBanner()
HistoryButton {
navController.navigate("control_history")
}
Text("Daftar Actuator", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(1.dp))
when (allActuators) {
is ControlState.Loading -> {
isRefreshing.value = false
@ -181,3 +180,30 @@ fun ControlActuatorScreen(
}
}
}
@Composable
private fun HistoryButton(onClick: () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = MainGreen
),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(color = MainGreen, width = 1.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Control History")
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
tint = MainGreen,
contentDescription = "button history"
)
}
}
}

View File

@ -1,11 +1,17 @@
package com.syaroful.agrilinkvocpro.control_feature.presentation.history
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
@ -19,15 +25,19 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.syaroful.agrilinkvocpro.control_feature.R
import com.syaroful.agrilinkvocpro.control_feature.core.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.core.utils.getRelativeTime
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorHistoryResponse
@ -53,7 +63,10 @@ fun ControlHistoryScreen(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text("Riwayat Kontrol Aktuator", style = MaterialTheme.typography.titleMedium)
Text(
"Riwayat Kontrol Aktuator",
style = MaterialTheme.typography.titleMedium
)
}
},
navigationIcon = {
@ -66,27 +79,53 @@ fun ControlHistoryScreen(
)
},
) { innerPadding ->
) { innerPadding ->
PullToRefreshBox(
modifier = Modifier
.fillMaxSize(),
isRefreshing = isRefreshing.value,
onRefresh = {
isRefreshing.value = true
controlHistoryViewModel.getActuatorHistory()
controlHistoryViewModel.loadInitialHistory()
},
) {
val historyList = controlHistoryViewModel.historyList
val listState = rememberLazyListState()
LaunchedEffect(listState) {
snapshotFlow {
val layoutInfo = listState.layoutInfo
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = layoutInfo.totalItemsCount
lastVisibleItem to totalItems
}.collect { (lastVisible, totalItems) ->
if (lastVisible >= totalItems - 1 && !controlHistoryViewModel.isLastPage) {
controlHistoryViewModel.loadMoreHistory()
}
}
}
LazyColumn(
state = listState,
modifier = Modifier
.padding(innerPadding)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (historyState) {
when (val state = historyState) {
is ControlState.Loading -> {
if (historyList.isEmpty()) {
item {
CircularProgressIndicator(color = MainGreen)
}
}
}
is ControlState.Error -> {
isRefreshing.value = false
item {
DefaultErrorComponent(
message = (historyState as ControlState.Error).message,
message = state.message,
label = "Oops!",
)
}
@ -94,52 +133,95 @@ fun ControlHistoryScreen(
is ControlState.Success -> {
isRefreshing.value = false
val data =
(historyState as ControlState.Success<ActuatorHistoryResponse>).data?.data
if (!data.isNullOrEmpty()) {
val reversedData = data.reversed()
items(reversedData) { historyData ->
ListItem(
leadingContent = {
Text("${historyData?.actuatorId}")
},
headlineContent = {
val state =
if (historyData?.action == "ON") "Diaktifkan oleh " else "Dinonaktifkan oleh "
Text(text = "$state ${historyData?.triggeredBy}")
},
supportingContent = {
Text(
text = getRelativeTime(historyData?.createdAt.toString())
)
},
trailingContent = {
Text("${historyData?.action}",
color = if (historyData?.action == "ON") MainGreen else Color.Red
)
},
shadowElevation = 2.dp
if (historyList.isEmpty()) {
item {
DefaultErrorComponent(
label = "Data kosong",
message = "Tidak ada riwayat kontrol yang tersedia"
)
}
} else {
item {
Text("Tidak ada data riwayat kontrol")
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
(historyState as? ControlState.Success<ActuatorHistoryResponse>)?.data?.let { response ->
Text("Total: ${response.total}", style = MaterialTheme.typography.titleSmall)
} ?: run {
Text("Total: -", style = MaterialTheme.typography.titleSmall)
}
// IconButton(
// modifier = Modifier
// .background(
// shape = RoundedCornerShape(4.dp),
// color = MainGreen.copy(alpha = 0.1f)
// )
// .size(40.dp),
// onClick = { },
// ) {
// Icon(
// modifier = Modifier.size(24.dp),
// painter = painterResource(R.drawable.ic_filter),
// tint = MainGreen,
// contentDescription = "Choose bet"
// )
// }
}
}
items(historyList) { historyData ->
historyData.let {
ListItem(
leadingContent = {
Icon(
painter = painterResource(id = R.drawable.power),
contentDescription = null,
tint = if (it.action == "ON") MainGreen else Color.Red
)
},
headlineContent = {
val stateText =
it.actuator?.name
Text("$stateText")
},
supportingContent = {
Text(it.triggeredBy ?: "", style = MaterialTheme.typography.bodySmall)
},
trailingContent = {
Text(getRelativeTime(it.createdAt ?: ""), style = MaterialTheme.typography.bodySmall)
},
shadowElevation = 0.dp,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.background(
color = Color.White,
shape = RoundedCornerShape(24.dp)
)
)
}
}
// Infinite scroll loading indicator
if (!controlHistoryViewModel.isLastPage) {
item {
CircularProgressIndicator(
color = MainGreen,
modifier = Modifier
.padding(16.dp)
.size(24.dp)
)
}
}
}
}
is ControlState.Loading -> {
item {
CircularProgressIndicator(
color = MainGreen
)
}
}
else -> {}
else -> Unit
}
}
}
}
}

View File

@ -1,15 +1,16 @@
package com.syaroful.agrilinkvocpro.control_feature.presentation.history
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syaroful.agrilinkvocpro.control_feature.core.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorHistoryData
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorHistoryResponse
import com.syaroful.agrilinkvocpro.control_feature.data.repository.ControlRepository
import com.syaroful.agrilinkvocpro.core.utils.extention.mapToUserFriendlyError
import com.syaroful.agrilinkvocpro.data.UserPreferences
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@ -22,35 +23,88 @@ class ControlHistoryViewModel(
private val _historyState =
MutableStateFlow<ControlState<ActuatorHistoryResponse>>(ControlState.Idle)
val historyState: MutableStateFlow<ControlState<ActuatorHistoryResponse>> = _historyState
val historyState: StateFlow<ControlState<ActuatorHistoryResponse>> = _historyState
private val _historyList = mutableListOf<ActuatorHistoryData>()
val historyList: List<ActuatorHistoryData> get() = _historyList
private var currentPage = 1
private val limitPerPage = 10
private var isLoadingMore = false
var isLastPage = false
private set
init {
getActuatorHistory()
loadInitialHistory()
}
fun getActuatorHistory() {
fun loadInitialHistory() {
_historyState.value = ControlState.Loading
// Reset pagination state
currentPage = 1
isLastPage = false
isLoadingMore = false
_historyList.clear()
viewModelScope.launch {
val token = userPreferences.tokenFlow.first()
val authHeader = "Bearer $token"
try {
delay(500L)
val response = repository.getActuatorHistory(authHeader = authHeader)
val response = repository.getActuatorHistory(
authHeader = authHeader,
page = currentPage,
limit = limitPerPage
)
if (response.isSuccessful) {
val data = response.body()
_historyState.value = ControlState.Success(data)
Log.d(TAG, "Successfully get Actuator History ${response.body()}")
val body = response.body()
_historyList.clear()
val safeData = body?.data?.filterNotNull() ?: emptyList()
_historyList.addAll(safeData)
isLastPage = currentPage >= (body?.lastPage ?: 1)
_historyState.value = ControlState.Success(body)
} else {
val errorBody = response.errorBody()?.string()
val errorMessage = errorBody ?: "Error ${response.code()}"
_historyState.value = ControlState.Error(errorMessage)
_historyState.value = ControlState.Error(response.message())
}
} catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e)
_historyState.value = ControlState.Error(errorMessage)
_historyState.value = ControlState.Error(mapToUserFriendlyError(e))
}
}
}
fun loadMoreHistory() {
if (isLoadingMore || isLastPage) return
isLoadingMore = true
currentPage += 1
viewModelScope.launch {
val token = userPreferences.tokenFlow.first()
val authHeader = "Bearer $token"
try {
val response = repository.getActuatorHistory(
authHeader = authHeader,
page = currentPage,
limit = limitPerPage
)
if (response.isSuccessful) {
val body = response.body()
val safeData = body?.data?.filterNotNull() ?: emptyList()
_historyList.addAll(safeData)
isLastPage = currentPage >= (body?.lastPage ?: 1)
_historyState.value = ControlState.Success(body)
} else {
currentPage -= 1
_historyState.value = ControlState.Error(response.message())
}
} catch (e: Exception) {
currentPage -= 1
_historyState.value = ControlState.Error(mapToUserFriendlyError(e))
} finally {
isLoadingMore = false
}
}
}
}

View File

@ -39,7 +39,7 @@ fun GrowthRecipeFeatureBanner(){
style = MaterialTheme.typography.titleMedium.copy(color = Color.White)
)
Text(
"Lihat rekomendasi perawatan optimal untuk setiap jenis tanaman",
"Lihat rekomendasi perawatan optimal",
style = MaterialTheme.typography.bodySmall.copy(Color.White.copy(alpha = 0.5f))
)
}

View File

@ -14,10 +14,13 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -28,6 +31,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@ -52,21 +56,25 @@ fun GrowthRecipeScreen(
navController: NavController,
viewModel: GrowthRecipeViewModel = koinViewModel()
) {
val commodityOptions = listOf("Labu Kabocha", "Melon", "Strawberry")
val sensorIdOptions: Map<String, String> = mapOf("Bed 1" to "npk1", "Bed 2" to "npk2")
val sensorOptions = listOf("Nitrogen", "Pospor", "Kalium")
val selectedSensor = remember { mutableStateOf("Nitrogen") }
val selectedSensorId = remember { mutableStateOf(sensorIdOptions.values.first()) }
val graphicState by viewModel.getGraphicState.collectAsState()
val isRefreshing = remember { mutableStateOf(false) }
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.getGraphicData("npk1")
viewModel.getGraphicData(selectedSensorId.value)
}
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
isRefreshing.value = true
viewModel.getGraphicData("npk1")
viewModel.getGraphicData(selectedSensorId.value)
},
) {
Scaffold(
@ -104,10 +112,20 @@ fun GrowthRecipeScreen(
},
title = "✨ Lihat saran perawatan"
)
Text(
"🪴 Grafik nutrisi dalam 10 hari",
style = MaterialTheme.typography.titleMedium
)
Row(
modifier = Modifier.fillMaxWidth()
,
horizontalArrangement = Arrangement.SpaceBetween
){
Text(
"🪴 Grafik nutrisi dalam 10 hari",
style = MaterialTheme.typography.titleMedium
)
Text(
selectedSensorId.value,
style = MaterialTheme.typography.titleMedium
)
}
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
@ -119,7 +137,7 @@ fun GrowthRecipeScreen(
color = MainGreen.copy(alpha = 0.1f)
)
.size(40.dp),
onClick = { },
onClick = {expanded = !expanded},
) {
Icon(
modifier = Modifier.size(24.dp),
@ -127,6 +145,27 @@ fun GrowthRecipeScreen(
tint = MainGreen,
contentDescription = "Choose bet"
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
sensorIdOptions.forEach { item ->
DropdownMenuItem(
text = {
Text(
item.key,
modifier = Modifier.fillMaxWidth()
)
},
colors = MenuDefaults.itemColors(MaterialTheme.colorScheme.onBackground),
onClick = {
selectedSensorId.value = item.value
expanded = false
viewModel.getGraphicData(selectedSensorId.value)
}
)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
DynamicBottomSheet(
@ -163,7 +202,7 @@ fun GrowthRecipeScreen(
isRefreshing.value = false
val dataList =
(graphicState as ResultState.Success<NpkGraphicDayResponse>).data?.data?.get(
"npk1"
selectedSensorId.value
).orEmpty()
val days = dataList.mapNotNull { it.day?.toInt() }
@ -191,7 +230,7 @@ fun GrowthRecipeScreen(
}
}
Text(
"🌱 Saran Nutrisi Optimal",
"🌱 Saran Perbandingan Nutrisi Optimal",
style = MaterialTheme.typography.titleMedium
)
Row(
@ -199,26 +238,34 @@ fun GrowthRecipeScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Column(horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Nutrisi")
Text("N")
Text("P")
Text("K")
}
Column(horizontalAlignment = Alignment.Start) {
Column(horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Vegetatif")
Text("30 ppm")
Text("40 ppm")
Text("50 ppm")
NutrientStandartBox("30")
NutrientStandartBox("10")
NutrientStandartBox("10")
}
Column(horizontalAlignment = Alignment.Start) {
Column(horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Generatif")
Text("50 ppm")
Text("60 ppm")
Text("70 ppm")
NutrientStandartBox("10")
NutrientStandartBox("20")
NutrientStandartBox("20")
}
}
}
}
}
}
@Composable
private fun NutrientStandartBox(
label: String
){
Box(modifier = Modifier.background(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceContainer).padding(horizontal = 8.dp, vertical = 4.dp)){ Text(label, style = MaterialTheme.typography.labelSmall) }
}

View File

@ -51,6 +51,7 @@ class GrowthRecipeViewModel(
sensor = sensor,
)
if (response.isSuccessful) {
Log.d(TAG, "Response: ${response.body()}")
response.body()?.let { body ->
_getGraphicState.value = ResultState.Success(body)
} ?: run {

View File

@ -40,6 +40,7 @@ import com.syaroful.agrilinkvocpro.core.components.DefaultErrorComponent
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.core.extention.toBitmap
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.local.entity.PlantDiagnosisEntity
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.presentation.detail.TextCardComponent
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
@OptIn(ExperimentalMaterial3Api::class)
@ -121,13 +122,13 @@ fun DetailHistoryScreen(
Box(
modifier = Modifier
.background(
color = Color.Red,
color = if (plantDiagnosis?.diagnosis == "Sehat") MainGreen else Color.Red,
shape = RoundedCornerShape(4.dp)
)
.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Text(
text = "Terkena Penyakit",
text = if (plantDiagnosis?.diagnosis == "Sehat") "Sehat" else "Terkena Penyakit",
color = Color.White,
style = MaterialTheme.typography.labelMedium
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB