Compare commits

...

4 Commits

Author SHA1 Message Date
Cutiful
4dab797cb9 feat: add some document 2025-07-30 13:57:19 +07:00
Cutiful
faa8f2ea76 refactor: fix some feature 2025-07-30 13:56:29 +07:00
Cutiful
ac5f7c1619 refactor: remove dummy images from control feature
This commit removes several placeholder image files (`img_dummy.jpg`, `img_dummy2.jpg`, `img_dummy3.jpg`, `img_dummy4.jpg`) from the `control_feature` module.
2025-07-30 13:55:43 +07:00
Cutiful
a822c1782e feat: implement commodity price prediction feature
This commit introduces the commodity price prediction feature, including:

- **Data Layer:**
    - Models for commodity prediction and price responses.
    - `CommodityApiService` for fetching data from the backend.
    - `CommodityRepository` to abstract data fetching logic.
- **DI Modules:**
    - `appModule` for providing `CommodityRepository`.
    - `networkModule` for setting up Retrofit and OkHttp.
    - `viewModelModule` for providing `PredictionViewModel` and `CommodityViewModel`.
- **Presentation Layer:**
    - `PredictionViewModel` and `CommodityViewModel` to handle business logic and expose data to UI.
    - `PredictionScreen` to display commodity price predictions with a dropdown to select commodities and detailed breakdown of future prices (tomorrow, next week, next month, next 3 months).
    - `CommodityPriceScreen` (initial setup, details to be implemented) to display current commodity prices.
    - `CommodityListItem` composable for displaying individual commodity price information.
    - `FeatureBanner` composable for both commodity price prediction and control features.
- **Navigation:**
    - `SetupNavigation` to define navigation routes for `CommodityPriceScreen` and `PredictionScreen`.
- **Core Utilities:**
    - `toRupiahFormat()` extension function to format numbers as Indonesian Rupiah.
    - `percentageChange()` function to calculate percentage difference.
2025-07-30 13:55:25 +07:00
66 changed files with 1859 additions and 399 deletions

View File

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

View File

@ -23,8 +23,8 @@ fun DownloadModuleConfirmationDialog(
val message = when (moduleName) { val message = when (moduleName) {
"control_feature" -> "Apakah Anda ingin mendownload fitur Kontrol Aktuator?" "control_feature" -> "Apakah Anda ingin mendownload fitur Kontrol Aktuator?"
"recipe_feature" -> "Apakah Anda ingin mendownload fitur Resep Pertumbuhan?" "growth_recipe_feature" -> "Apakah Anda ingin mendownload fitur Formula Pertumbuhan Optimal?"
"price_prediction_feature" -> "Apakah Anda ingin mendownload fitur Prediksi Harga Komoditas?" "commodity_price_prediction_feature" -> "Apakah Anda ingin mendownload fitur Prediksi Harga Komoditas?"
"plant_disease_detection_feature" -> "Apakah Anda ingin mendownload fitur Deteksi Penyakit Tanaman?" "plant_disease_detection_feature" -> "Apakah Anda ingin mendownload fitur Deteksi Penyakit Tanaman?"
else -> "Apakah Anda ingin mendownload modul ini?" else -> "Apakah Anda ingin mendownload modul ini?"
} }
@ -69,7 +69,7 @@ fun DownloadModuleConfirmationDialog(
fun PreviewDownloadModuleConfirmationDialog() { fun PreviewDownloadModuleConfirmationDialog() {
DownloadModuleConfirmationDialog( DownloadModuleConfirmationDialog(
moduleName = "price_prediction_feature", moduleName = "commodity_price_prediction_feature",
onClickConfirm = {}, onClickConfirm = {},
onDismiss = {} 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 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.DhtGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.LoginResponse import com.syaroful.agrilinkvocpro.data.model.LoginResponse
import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.RegisterResponse import com.syaroful.agrilinkvocpro.data.model.RegisterResponse
import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorInformationResponse
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@ -61,4 +64,16 @@ interface ApiService {
@Query("sensor") sensor: String @Query("sensor") sensor: String
): Response<DhtGraphicDataResponse> ): 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 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.DhtGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorInformationResponse
import com.syaroful.agrilinkvocpro.data.network.ApiService import com.syaroful.agrilinkvocpro.data.network.ApiService
import retrofit2.Response import retrofit2.Response
@ -38,4 +40,19 @@ class SensorDataRepository(private val apiService: ApiService) {
): Response<DhtGraphicDataResponse> { ): Response<DhtGraphicDataResponse> {
return apiService.getDhtDataSensor(authHeader, startDate, endDate, timeRange, sensor) 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 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.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -17,6 +24,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.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.getCurrentDate
import com.syaroful.agrilinkvocpro.core.utils.extention.toFormattedString import com.syaroful.agrilinkvocpro.core.utils.extention.toFormattedString
import com.syaroful.agrilinkvocpro.presentation.screen.detail.component.DetailDhtContent import com.syaroful.agrilinkvocpro.presentation.screen.detail.component.DetailDhtContent
@ -33,23 +44,27 @@ fun DetailScreen(
detailViewModel: DetailViewModel = koinViewModel(), detailViewModel: DetailViewModel = koinViewModel(),
) { ) {
val date = remember { mutableStateOf(Date().toFormattedString()) } val date = remember { mutableStateOf(Date().toFormattedString()) }
val id = if (sensorId == "dht") 1 else if (sensorId == "npk1") 2 else 3
LaunchedEffect(sensorId) { LaunchedEffect(sensorId) {
if (sensorId == "dht") { if (sensorId == "dht") {
detailViewModel.fetchDhtData(date = date.value, sensor = sensorId) detailViewModel.fetchDhtData(date = date.value, sensor = sensorId)
detailViewModel.fetchSensorInformation(id)
} else { } else {
detailViewModel.fetchNpkData(date = date.value, sensor = sensorId) detailViewModel.fetchNpkData(date = date.value, sensor = sensorId)
detailViewModel.fetchSensorInformation(id)
} }
} }
val npkDataState by detailViewModel.npkDataState.collectAsState() val npkDataState by detailViewModel.npkDataState.collectAsState()
val dhtDataState by detailViewModel.dhtDataState.collectAsState() val dhtDataState by detailViewModel.dhtDataState.collectAsState()
val bedState by detailViewModel.bedState.collectAsState()
val currentData = detailViewModel.currentSensorData val currentData = detailViewModel.currentSensorData
val isRefreshing = remember { mutableStateOf(false) } val isRefreshing = remember { mutableStateOf(false) }
val currentDate = remember { mutableStateOf(getCurrentDate()) } val currentDate = remember { mutableStateOf(getCurrentDate()) }
Scaffold( Scaffold(
topBar = { DetailTopBar() }) { innerPadding -> topBar = { DetailTopBar() }) { innerPadding ->
PullToRefreshBox( PullToRefreshBox(
@ -61,8 +76,53 @@ fun DetailScreen(
detailViewModel.fetchNpkData(date = date.value, sensor = sensorId) detailViewModel.fetchNpkData(date = date.value, sensor = sensorId)
} }
}) { }) {
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( if (sensorId == "dht") DetailDhtContent(
modifier = Modifier.padding(innerPadding),
viewModel = detailViewModel, viewModel = detailViewModel,
dhtDataState = dhtDataState, dhtDataState = dhtDataState,
currentData = currentData, currentData = currentData,
@ -72,7 +132,6 @@ fun DetailScreen(
currentDate = currentDate currentDate = currentDate
) )
else DetailNpkContent( else DetailNpkContent(
modifier = Modifier.padding(innerPadding),
viewModel = detailViewModel, viewModel = detailViewModel,
npkDataState = npkDataState, npkDataState = npkDataState,
currentData = currentData, currentData = currentData,
@ -81,6 +140,8 @@ fun DetailScreen(
date = date, date = date,
currentDate = currentDate 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.mapToUserFriendlyError
import com.syaroful.agrilinkvocpro.core.utils.extention.toFormattedString import com.syaroful.agrilinkvocpro.core.utils.extention.toFormattedString
import com.syaroful.agrilinkvocpro.data.UserPreferences 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.DhtGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse import com.syaroful.agrilinkvocpro.data.model.NpkGraphicDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse import com.syaroful.agrilinkvocpro.data.model.SensorDataResponse
import com.syaroful.agrilinkvocpro.data.model.SensorInformationResponse
import com.syaroful.agrilinkvocpro.data.repository.SensorDataRepository import com.syaroful.agrilinkvocpro.data.repository.SensorDataRepository
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -30,14 +32,75 @@ class DetailViewModel(
val currentSensorData: SensorDataResponse? val currentSensorData: SensorDataResponse?
get() = sensorDataRepository.latestSensorData 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() 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() 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() 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( fun fetchNpkData(
date: String = today.toFormattedString(), date: String = today.toFormattedString(),
sensor: String, sensor: String,
@ -62,7 +125,8 @@ class DetailViewModel(
_npkDataState.value = ResultState.Error("Data tidak ditemukan") _npkDataState.value = ResultState.Error("Data tidak ditemukan")
} }
} else { } else {
_npkDataState.value = ResultState.Error("Error: ${response.code()} - ${response.message()}") _npkDataState.value =
ResultState.Error("Error: ${response.code()} - ${response.message()}")
} }
} catch (e: Exception) { } catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e) val errorMessage = mapToUserFriendlyError(e)
@ -96,7 +160,8 @@ class DetailViewModel(
_dhtDataState.value = ResultState.Error("Data tidak ditemukan") _dhtDataState.value = ResultState.Error("Data tidak ditemukan")
} }
} else { } else {
_dhtDataState.value = ResultState.Error("Error: ${response.code()} - ${response.message()}") _dhtDataState.value =
ResultState.Error("Error: ${response.code()} - ${response.message()}")
} }
} catch (e: Exception) { } catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e) 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -54,7 +52,6 @@ fun DetailDhtContent(
Column( Column(
modifier = modifier modifier = modifier
.padding(16.dp) .padding(16.dp)
.verticalScroll(rememberScrollState())
) { ) {
when (dhtDataState) { when (dhtDataState) {
is ResultState.Loading -> { 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -57,7 +55,6 @@ fun DetailNpkContent(
Column( Column(
modifier = modifier modifier = modifier
.padding(16.dp) .padding(16.dp)
.verticalScroll(rememberScrollState())
) { ) {
when (npkDataState) { when (npkDataState) {
is ResultState.Loading -> { is ResultState.Loading -> {

View File

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

View File

@ -324,22 +324,22 @@ fun DynamicFeatureSection(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
MenuItemButton( MenuItemButton(
label = "Kontrol\nAktuator", label = "Control\nActuator",
icon = painterResource(id = R.drawable.control_actuator_icon), icon = painterResource(id = R.drawable.control_actuator_icon),
onClick = { onFeatureClick("control_feature") }, onClick = { onFeatureClick("control_feature") },
) )
MenuItemButton( MenuItemButton(
label = "Resep\nPertumbuhan", label = "Growth\nFormula",
icon = painterResource(id = R.drawable.growth_recipe_icon), icon = painterResource(id = R.drawable.growth_recipe_icon),
onClick = { onFeatureClick("growth_recipe_feature") }, onClick = { onFeatureClick("growth_recipe_feature") },
) )
MenuItemButton( MenuItemButton(
label = "Harga\nKomoditas", label = "Commodity\nPrice",
icon = painterResource(id = R.drawable.commodity_price_prediction_icon), icon = painterResource(id = R.drawable.commodity_price_prediction_icon),
onClick = { onFeatureClick("commodity_price_prediction_feature") }, onClick = { onFeatureClick("commodity_price_prediction_feature") },
) )
MenuItemButton( MenuItemButton(
label = "Deteksi\nPenyakit", label = "Disease\nDetection",
icon = painterResource(id = R.drawable.plant_disease_detection_icon), icon = painterResource(id = R.drawable.plant_disease_detection_icon),
onClick = { onFeatureClick("plant_disease_detection_feature") }, onClick = { onFeatureClick("plant_disease_detection_feature") },
) )
@ -378,7 +378,7 @@ fun GreenHouseInformationSection(navController: NavController) {
modifier = Modifier.weight(1f) 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)) Spacer(modifier = Modifier.height(24.dp))
Text(text = "Green House Bumiaji", style = textTheme.bodyLarge) Text(text = "Green House Bumiaji", style = textTheme.bodyLarge)
Text( 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) private val _homeState = MutableStateFlow<ResultState<SensorDataResponse>>(ResultState.Idle)
val homeState: StateFlow<ResultState<SensorDataResponse>> = _homeState val homeState: StateFlow<ResultState<SensorDataResponse>> = _homeState
// var currentDataSensor: SensorDataResponse? = null
// private set
init { init {
getGreenHouseData() getGreenHouseData()
} }

View File

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

View File

@ -7,7 +7,7 @@
<string name="control_feature_label">Kontrol Aktuator</string> <string name="control_feature_label">Kontrol Aktuator</string>
<string name="play_store_icon_desc">Google Play Store</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_module_message">anda perlu mengunduh modul fitur dinamis agar fitur ini dapat digunakan</string>
<string name="download">Download</string> <string name="download">Download</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>

View File

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

View File

@ -0,0 +1,54 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.component
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.R
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
@Composable
fun FeatureBanner(){
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = MainGreen, shape = RoundedCornerShape(8.dp))
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.fillMaxWidth(0.6f)) {
Text(
"Prediksi Harga",
style = MaterialTheme.typography.titleMedium.copy(color = Color.White)
)
Text(
"Agar kamu bisa memilih komoditas yang tepat",
style = MaterialTheme.typography.bodySmall.copy(Color.White.copy(alpha = 0.5f))
)
}
Image(
modifier = Modifier.fillMaxWidth(0.8f),
painter = painterResource(id = R.drawable.commodity_img_banner),
contentDescription = "Plant Image",
)
}
}
}

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,16 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.utils
import java.text.NumberFormat
import java.util.Locale
fun Number.toRupiahFormat(): String {
val localeID = Locale("in", "ID")
val numberFormat = NumberFormat.getCurrencyInstance(localeID)
return numberFormat.format(this.toLong()).replace(",00", "")
}
fun percentageChange(from: Number, to: Number): Double {
if (from.toDouble() == 0.0 || to.toDouble() == 0.0) return 0.0
val change = ((to.toDouble() - from.toDouble()) / from.toDouble()) * 100
return change
}

View File

@ -0,0 +1,32 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model
data class CommodityPredictionResponse(
val harga: Double? = null,
val nama_komoditas: String,
val tanggal: String? = null,
val val_1: Double? = null,
val val_7: Double? = null,
val val_30: Double? = null,
val val_90: Double? = null
)
//class CommodityPredictionDeserializer : JsonDeserializer<CommodityPredictionResponse> {
// override fun deserialize(
// json: JsonElement,
// typeOfT: Type,
// context: JsonDeserializationContext
// ): CommodityPredictionResponse {
// val jsonObject = json.asJsonObject
// val name = jsonObject.get("nama_komoditas").asString
// val date = jsonObject.get("tanggal").asString
//
// val predictions = mutableMapOf<String, Double>()
// jsonObject.entrySet().forEach { entry ->
// if (entry.key.startsWith("val_")) {
// predictions[entry.key] = entry.value.asDouble
// }
// }
//
// return CommodityPredictionResponse(name, date, predictions)
// }
//}

View File

@ -0,0 +1,10 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model
data class CommodityPriceResponseItem(
val harga: Int?,
val kab_kota: String?,
val komoditas_nama: String?,
val pasar: String?,
val satuan: String?,
val tanggal: String?
)

View File

@ -0,0 +1,22 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.network
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPredictionResponse
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPriceResponseItem
import retrofit2.http.GET
import retrofit2.http.Query
interface CommodityApiService {
@GET("api/periodic-data")
suspend fun getCommodityPredictions(
@Query("tanggal") date: String,
): List<CommodityPredictionResponse>
@GET("api/harga")
suspend fun getCommodityPrice(
@Query("komoditas") komoditas: String,
@Query("pasar") pasar: String,
@Query("tanggal") tanggal: String,
@Query("kab_kota") kabKota: String
): List<CommodityPriceResponseItem>
}

View File

@ -0,0 +1,15 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.repository
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPredictionResponse
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPriceResponseItem
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.network.CommodityApiService
class CommodityRepository(private val apiService: CommodityApiService) {
suspend fun fetchCommodityPredictions(date: String): List<CommodityPredictionResponse> {
return apiService.getCommodityPredictions(date)
}
suspend fun fetchCommodityPrice(commodityName: String, marketName: String, date: String, city: String): List<CommodityPriceResponseItem> {
return apiService.getCommodityPrice(komoditas = commodityName, pasar = marketName, tanggal = date, kabKota = city)
}
}

View File

@ -0,0 +1,8 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.di
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.repository.CommodityRepository
import org.koin.dsl.module
val appModule = module {
single { CommodityRepository(get()) }
}

View File

@ -0,0 +1,26 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.di
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.network.CommodityApiService
import okhttp3.OkHttpClient
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
val networkModule = module {
single {
OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.SECONDS)
.build()
}
single {
Retrofit.Builder()
.baseUrl("http://labai.polinema.ac.id:50/")
.client(get())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
single { get<Retrofit>().create(CommodityApiService::class.java) }
}

View File

@ -0,0 +1,11 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.di
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity.CommodityViewModel
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.prediction.PredictionViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val viewModelModule = module {
viewModel { PredictionViewModel(get()) }
viewModel { CommodityViewModel(get()) }
}

View File

@ -0,0 +1,20 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_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.commodity_price_prediction_feature.presentation.commodity.CommodityPriceScreen
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.prediction.PredictionScreen
@Composable
fun SetupNavigation(navController: NavHostController) {
NavHost(navController = navController, startDestination = "commodity_screen") {
composable("commodity_screen") {
CommodityPriceScreen(navController = navController)
}
composable("prediction_screen") {
PredictionScreen(navController = navController)
}
}
}

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,51 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPriceResponseItem
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.repository.CommodityRepository
import com.syaroful.agrilinkvocpro.core.utils.ResultState
import com.syaroful.agrilinkvocpro.core.utils.extention.mapToUserFriendlyError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
private const val TAG = "CommodityViewModel"
class CommodityViewModel(
private val repository: CommodityRepository
) : ViewModel() {
private val _state = MutableStateFlow<ResultState<List<CommodityPriceResponseItem>>>(
ResultState.Idle
)
val state: StateFlow<ResultState<List<CommodityPriceResponseItem>>> = _state
fun loadCommodityPrice(
commodityName: String,
market: String,
date: String,
city: String,
) {
viewModelScope.launch {
_state.value = ResultState.Loading
try {
val data = repository.fetchCommodityPrice(
commodityName = commodityName,
marketName = market,
date = date,
city = city
)
Log.d(TAG, "loadCommodityPrice: $commodityName, $market, $date, $city")
_state.value = ResultState.Success(data)
Log.d(TAG, "loadCommodityPrice: $data")
} catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e)
_state.value = ResultState.Error(errorMessage)
Log.e(TAG, "loadCommodityPrice: $errorMessage", e)
}
}
}
}

View File

@ -0,0 +1,51 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.commodity.component
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.utils.toRupiahFormat
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPriceResponseItem
@Composable
fun CommodityListItem(item: CommodityPriceResponseItem) {
ListItem(
modifier = Modifier.border(
shape = RoundedCornerShape(8.dp), width = 1.dp, color =
MaterialTheme.colorScheme.surfaceContainer
),
headlineContent = {
Text(
text = "${item.kab_kota} - ${item.pasar}",
style = MaterialTheme.typography.bodyMedium,
)
},
supportingContent = {
Text(
text = item.komoditas_nama.toString(),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.alpha(0.5f)
)
},
trailingContent = {
Box(
modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = RoundedCornerShape(8.dp)).padding(vertical = 4.dp, horizontal = 8.dp)
)
{
Text(
text = "${item.harga?.toRupiahFormat()}",
style = MaterialTheme.typography.labelMedium
)
}
}
)
}

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

@ -0,0 +1,329 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.prediction
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.Box
import androidx.compose.foundation.layout.Column
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.layout.size
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.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.OutlinedButton
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.runtime.setValue
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.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.R
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.utils.percentageChange
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.core.utils.toRupiahFormat
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPredictionResponse
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 com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
import org.koin.androidx.compose.koinViewModel
import java.util.Calendar
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PredictionScreen(
navController: NavHostController,
viewModel: PredictionViewModel = koinViewModel()
) {
val resultState by viewModel.commodityData.collectAsState()
val calendar = remember { Calendar.getInstance().apply { add(Calendar.DATE, -1) } }
val today = Date().toFormattedString()
val dateYesterday = calendar.time.toFormattedString()
val isRefreshing = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (resultState == ResultState.Idle) {
viewModel.loadCommodityPredictions(today)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"Price Prediction",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
)
}
) { innerPadding ->
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
viewModel.loadCommodityPredictions(today)
}
) {
when (resultState) {
is ResultState.Idle -> {
}
is ResultState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Loader()
}
}
is ResultState.Success -> {
isRefreshing.value = false
val data =
(resultState as ResultState.Success<List<CommodityPredictionResponse>>).data
if (!data.isNullOrEmpty()) {
var expanded by remember { mutableStateOf(false) }
var selectedCommodity by remember {
mutableStateOf<CommodityPredictionResponse?>(
data[0]
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
Box {
OutlinedButton(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
border = BorderStroke(
color = MainGreen.copy(alpha = 0.5f),
width = 1.dp
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedCommodity?.nama_komoditas
?: "Pilih Komoditas",
color = MaterialTheme.colorScheme.onBackground
)
Image(
imageVector = Icons.Default.ArrowDropDown,
alpha = 0.5f,
contentDescription = "dropdown"
)
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
data.forEach { item ->
DropdownMenuItem(
text = {
Text(
item.nama_komoditas,
modifier = Modifier.fillMaxWidth()
)
},
colors = MenuDefaults.itemColors(MaterialTheme.colorScheme.onBackground),
onClick = {
selectedCommodity = item
expanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
selectedCommodity?.let { commodity ->
CommodityPredictionCard(commodity)
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text("Data kosong.")
}
}
}
is ResultState.Error -> {
isRefreshing.value = false
val message = (resultState as ResultState.Error).message
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.Center
) {
DefaultErrorComponent(
label = "Oops!",
message = message
)
}
}
}
}
}
}
@Composable
private fun PredictedPriceListItem(
periode: String,
harga: String,
percentage: Double,
) {
ListItem(
supportingContent = {
Text(
harga,
style = MaterialTheme.typography.titleLarge
)
},
headlineContent = {
Text(
periode,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.alpha(0.5f)
)
},
trailingContent = {
val graphic: String = if (percentage >= 0) "" else ""
Text(
"$graphic ${"%.2f".format(percentage)}%",
style = MaterialTheme.typography.bodyMedium.copy(
color = if (percentage >= 0) MainGreen else Color.Red
)
)
}
)
}
@Composable
private fun CommodityPredictionCard(item: CommodityPredictionResponse) {
val hargaHariIni = item.harga?.toInt() ?: 0
val hargaBesok = item.val_1?.toInt() ?: 0
val hargaMinggu = item.val_7?.toInt() ?: 0
val hargaBulan = item.val_30?.toInt() ?: 0
val harga3Bulan = item.val_90?.toInt() ?: 0
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(12.dp)
)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"Komoditas",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.alpha(0.5f)
)
Text(item.nama_komoditas, style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Harga Hari Ini",
style = MaterialTheme.typography.bodySmall
)
Text(
"${item.harga?.toRupiahFormat()}",
style = MaterialTheme.typography.headlineSmall
)
}
Image(
painter = painterResource(
when (item.nama_komoditas) {
"Tomat Merah" -> R.drawable.tomat_merah
"Cabe Merah Besar" -> R.drawable.cabe_merah_besar
"Cabe Rawit Merah" -> R.drawable.cabe_rawit
"Bawang Merah" -> R.drawable.bawang_merah
"Bawang Putih Sinco/Honan" -> R.drawable.bawang_putih
else -> R.drawable.bawang_merah
}
),
contentDescription = "commodity",
modifier = Modifier.size(64.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
PredictedPriceListItem(
"Besok",
hargaBesok.toRupiahFormat(),
percentageChange(hargaHariIni, hargaBesok)
)
PredictedPriceListItem(
"Minggu Depan",
hargaMinggu.toRupiahFormat(),
percentageChange(hargaHariIni, hargaMinggu)
)
PredictedPriceListItem(
"Bulan Depan",
hargaBulan.toRupiahFormat(),
percentageChange(hargaHariIni, hargaBulan)
)
PredictedPriceListItem(
"3 Bulan ke Depan",
harga3Bulan.toRupiahFormat(),
percentageChange(hargaHariIni, harga3Bulan)
)
}
}

View File

@ -0,0 +1,40 @@
package com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.presentation.prediction
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.model.CommodityPredictionResponse
import com.syaroful.agrilinkvocpro.commodity_price_prediction_feature.data.repository.CommodityRepository
import com.syaroful.agrilinkvocpro.core.utils.ResultState
import com.syaroful.agrilinkvocpro.core.utils.extention.mapToUserFriendlyError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
private const val TAG = "PredictionViewModel"
class PredictionViewModel(
private val repository: CommodityRepository
) : ViewModel() {
private val _commodityData = MutableStateFlow<ResultState<List<CommodityPredictionResponse>>>(
ResultState.Idle
)
val commodityData: StateFlow<ResultState<List<CommodityPredictionResponse>>> = _commodityData
fun loadCommodityPredictions(date: String) {
viewModelScope.launch {
_commodityData.value = ResultState.Loading
try {
val data = repository.fetchCommodityPredictions(date = date)
_commodityData.value = ResultState.Success(data)
Log.d(TAG, "loadCommodityPredictions: $data")
} catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e)
_commodityData.value = ResultState.Error(errorMessage)
Log.e(TAG, "loadCommodityPredictions: $errorMessage")
}
}
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -0,0 +1,53 @@
package com.syaroful.agrilinkvocpro.control_feature.core.components
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 com.syaroful.agrilinkvocpro.control_feature.R
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
@Composable
fun FeatureBanner(){
Box(
modifier = Modifier
.fillMaxWidth()
.background(color = MainGreen, shape = RoundedCornerShape(8.dp))
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.fillMaxWidth(0.6f)) {
Text(
"Kontrol Penyiraman",
style = MaterialTheme.typography.titleMedium.copy(color = Color.White)
)
Text(
"lakukan penyiraman hanya dengan sekali klik dari jarak jauh",
style = MaterialTheme.typography.bodySmall.copy(Color.White.copy(alpha = 0.5f))
)
}
Image(
modifier = Modifier.fillMaxWidth(0.8f),
painter = painterResource(id = R.drawable.watering_img_banner),
contentDescription = "Plant Image",
)
}
}
}

View File

@ -1,13 +1,69 @@
package com.syaroful.agrilinkvocpro.control_feature.data.model package com.syaroful.agrilinkvocpro.control_feature.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ActuatorHistoryResponse( data class ActuatorHistoryResponse(
val `data`: List<ActuatorLog?>?, @SerialName("data")
val `data`: List<ActuatorHistoryData?>?,
@SerialName("lastPage")
val lastPage: Int?, val lastPage: Int?,
@SerialName("message")
val message: String?, val message: String?,
@SerialName("nextPage")
val nextPage: Any?, val nextPage: Any?,
@SerialName("page")
val page: Int?, val page: Int?,
@SerialName("perPage")
val perPage: Int?, val perPage: Int?,
@SerialName("previousPage")
val previousPage: Any?, val previousPage: Any?,
@SerialName("statusCode")
val statusCode: Int?, val statusCode: Int?,
@SerialName("total")
val total: Int? 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

@ -0,0 +1,6 @@
package com.syaroful.agrilinkvocpro.control_feature.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

View File

@ -10,6 +10,7 @@ import retrofit2.http.Header
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Query
interface ControlService { interface ControlService {
// get all actuator status // get all actuator status
@ -43,8 +44,17 @@ interface ControlService {
): Response<ControlLogResponse> ): Response<ControlLogResponse>
// get controls log // get controls log
@GET("api//actuator-control-logs") @GET("api/actuator-control-logs")
suspend fun getActuatorsControlLog( suspend fun getActuatorsControlLog(
@Header("Authorization") authHeader: String, @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> ): Response<ActuatorHistoryResponse>
} }

View File

@ -37,8 +37,29 @@ class ControlRepository(
return controlService.getActuatorStatus(authHeader) return controlService.getActuatorStatus(authHeader)
} }
suspend fun getActuatorHistory(authHeader: String): Response<ActuatorHistoryResponse> { suspend fun getActuatorHistory(
return controlService.getActuatorsControlLog(authHeader) 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 package com.syaroful.agrilinkvocpro.control_feature.presentation.control
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost 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.ControlCard
import com.syaroful.agrilinkvocpro.control_feature.core.components.CustomSnackBar 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.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.core.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorType import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorType
import com.syaroful.agrilinkvocpro.core.placeholder.shimmerEffect import com.syaroful.agrilinkvocpro.core.placeholder.shimmerEffect
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -88,14 +93,6 @@ fun ControlActuatorScreen(
Text("Control Actuator", style = MaterialTheme.typography.titleMedium) Text("Control Actuator", style = MaterialTheme.typography.titleMedium)
} }
}, },
actions = {
IconButton(onClick = {
navController.navigate("control_history")
}) {
Icon(Icons.Filled.Refresh, contentDescription = "History")
}
}
) )
} }
) { innerPadding -> ) { innerPadding ->
@ -115,9 +112,11 @@ fun ControlActuatorScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
FeatureBanner()
HistoryButton {
navController.navigate("control_history")
}
Text("Daftar Actuator", style = MaterialTheme.typography.titleMedium) Text("Daftar Actuator", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(1.dp))
when (allActuators) { when (allActuators) {
is ControlState.Loading -> { is ControlState.Loading -> {
isRefreshing.value = false 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 package com.syaroful.agrilinkvocpro.control_feature.presentation.history
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -19,15 +25,19 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
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
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController 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.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.core.utils.getRelativeTime import com.syaroful.agrilinkvocpro.control_feature.core.utils.getRelativeTime
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorHistoryResponse import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorHistoryResponse
@ -53,7 +63,10 @@ fun ControlHistoryScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Riwayat Kontrol Aktuator", style = MaterialTheme.typography.titleMedium) Text(
"Riwayat Kontrol Aktuator",
style = MaterialTheme.typography.titleMedium
)
} }
}, },
navigationIcon = { navigationIcon = {
@ -68,25 +81,51 @@ fun ControlHistoryScreen(
) { innerPadding -> ) { innerPadding ->
PullToRefreshBox( PullToRefreshBox(
modifier = Modifier
.fillMaxSize(),
isRefreshing = isRefreshing.value, isRefreshing = isRefreshing.value,
onRefresh = { onRefresh = {
isRefreshing.value = true 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( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxWidth(), .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, 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 -> { is ControlState.Error -> {
isRefreshing.value = false isRefreshing.value = false
item { item {
DefaultErrorComponent( DefaultErrorComponent(
message = (historyState as ControlState.Error).message, message = state.message,
label = "Oops!", label = "Oops!",
) )
} }
@ -94,52 +133,95 @@ fun ControlHistoryScreen(
is ControlState.Success -> { is ControlState.Success -> {
isRefreshing.value = false isRefreshing.value = false
val data = if (historyList.isEmpty()) {
(historyState as ControlState.Success<ActuatorHistoryResponse>).data?.data item {
if (!data.isNullOrEmpty()) { DefaultErrorComponent(
val reversedData = data.reversed() label = "Data kosong",
items(reversedData) { historyData -> message = "Tidak ada riwayat kontrol yang tersedia"
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
) )
} }
} else { } else {
item { 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)
} }
is ControlState.Loading -> { // IconButton(
item { // modifier = Modifier
CircularProgressIndicator( // .background(
color = MainGreen // 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)
)
) )
} }
} }
else -> {} // Infinite scroll loading indicator
if (!controlHistoryViewModel.isLastPage) {
item {
CircularProgressIndicator(
color = MainGreen,
modifier = Modifier
.padding(16.dp)
.size(24.dp)
)
} }
} }
} }
} }
else -> Unit
}
}
}
}
} }

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -39,7 +39,7 @@ fun GrowthRecipeFeatureBanner(){
style = MaterialTheme.typography.titleMedium.copy(color = Color.White) style = MaterialTheme.typography.titleMedium.copy(color = Color.White)
) )
Text( Text(
"Lihat rekomendasi perawatan optimal untuk setiap jenis tanaman", "Lihat rekomendasi perawatan optimal",
style = MaterialTheme.typography.bodySmall.copy(Color.White.copy(alpha = 0.5f)) 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -28,6 +31,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -52,21 +56,25 @@ fun GrowthRecipeScreen(
navController: NavController, navController: NavController,
viewModel: GrowthRecipeViewModel = koinViewModel() 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 sensorOptions = listOf("Nitrogen", "Pospor", "Kalium")
val selectedSensor = remember { mutableStateOf("Nitrogen") } val selectedSensor = remember { mutableStateOf("Nitrogen") }
val selectedSensorId = remember { mutableStateOf(sensorIdOptions.values.first()) }
val graphicState by viewModel.getGraphicState.collectAsState() val graphicState by viewModel.getGraphicState.collectAsState()
val isRefreshing = remember { mutableStateOf(false) } val isRefreshing = remember { mutableStateOf(false) }
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.getGraphicData("npk1") viewModel.getGraphicData(selectedSensorId.value)
} }
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing.value, isRefreshing = isRefreshing.value,
onRefresh = { onRefresh = {
isRefreshing.value = true isRefreshing.value = true
viewModel.getGraphicData("npk1") viewModel.getGraphicData(selectedSensorId.value)
}, },
) { ) {
Scaffold( Scaffold(
@ -104,10 +112,20 @@ fun GrowthRecipeScreen(
}, },
title = "✨ Lihat saran perawatan" title = "✨ Lihat saran perawatan"
) )
Row(
modifier = Modifier.fillMaxWidth()
,
horizontalArrangement = Arrangement.SpaceBetween
){
Text( Text(
"🪴 Grafik nutrisi dalam 10 hari", "🪴 Grafik nutrisi dalam 10 hari",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text(
selectedSensorId.value,
style = MaterialTheme.typography.titleMedium
)
}
Row( Row(
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -119,7 +137,7 @@ fun GrowthRecipeScreen(
color = MainGreen.copy(alpha = 0.1f) color = MainGreen.copy(alpha = 0.1f)
) )
.size(40.dp), .size(40.dp),
onClick = { }, onClick = {expanded = !expanded},
) { ) {
Icon( Icon(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
@ -127,6 +145,27 @@ fun GrowthRecipeScreen(
tint = MainGreen, tint = MainGreen,
contentDescription = "Choose bet" 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)) Spacer(modifier = Modifier.width(8.dp))
DynamicBottomSheet( DynamicBottomSheet(
@ -163,7 +202,7 @@ fun GrowthRecipeScreen(
isRefreshing.value = false isRefreshing.value = false
val dataList = val dataList =
(graphicState as ResultState.Success<NpkGraphicDayResponse>).data?.data?.get( (graphicState as ResultState.Success<NpkGraphicDayResponse>).data?.data?.get(
"npk1" selectedSensorId.value
).orEmpty() ).orEmpty()
val days = dataList.mapNotNull { it.day?.toInt() } val days = dataList.mapNotNull { it.day?.toInt() }
@ -191,7 +230,7 @@ fun GrowthRecipeScreen(
} }
} }
Text( Text(
"🌱 Saran Nutrisi Optimal", "🌱 Saran Perbandingan Nutrisi Optimal",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Row( Row(
@ -199,26 +238,34 @@ fun GrowthRecipeScreen(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column(horizontalAlignment = Alignment.Start) { Column(horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Nutrisi") Text("Nutrisi")
Text("N") Text("N")
Text("P") Text("P")
Text("K") Text("K")
} }
Column(horizontalAlignment = Alignment.Start) { Column(horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Vegetatif") Text("Vegetatif")
Text("30 ppm") NutrientStandartBox("30")
Text("40 ppm") NutrientStandartBox("10")
Text("50 ppm") NutrientStandartBox("10")
} }
Column(horizontalAlignment = Alignment.Start) { Column(horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Generatif") Text("Generatif")
Text("50 ppm") NutrientStandartBox("10")
Text("60 ppm") NutrientStandartBox("20")
Text("70 ppm") 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, sensor = sensor,
) )
if (response.isSuccessful) { if (response.isSuccessful) {
Log.d(TAG, "Response: ${response.body()}")
response.body()?.let { body -> response.body()?.let { body ->
_getGraphicState.value = ResultState.Success(body) _getGraphicState.value = ResultState.Success(body)
} ?: run { } ?: 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.core.extention.toBitmap
import com.syaroful.agrilinkvocpro.plant_disease_detection_feature.data.local.entity.PlantDiagnosisEntity 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.plant_disease_detection_feature.presentation.detail.TextCardComponent
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -121,13 +122,13 @@ fun DetailHistoryScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.background( .background(
color = Color.Red, color = if (plantDiagnosis?.diagnosis == "Sehat") MainGreen else Color.Red,
shape = RoundedCornerShape(4.dp) shape = RoundedCornerShape(4.dp)
) )
.padding(vertical = 4.dp, horizontal = 8.dp) .padding(vertical = 4.dp, horizontal = 8.dp)
) { ) {
Text( Text(
text = "Terkena Penyakit", text = if (plantDiagnosis?.diagnosis == "Sehat") "Sehat" else "Terkena Penyakit",
color = Color.White, color = Color.White,
style = MaterialTheme.typography.labelMedium style = MaterialTheme.typography.labelMedium
) )

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB