feat: implement actuator control history feature

This commit is contained in:
Cutiful 2025-07-05 11:21:56 +07:00
parent c147cd0151
commit 66b7159bf6
5 changed files with 248 additions and 4 deletions

View File

@ -0,0 +1,28 @@
package com.syaroful.agrilinkvocpro.control_feature.core.utils
import java.time.Duration
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
fun getRelativeTime(isoTime: String): String {
return try {
val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME
val dateTime = OffsetDateTime.parse(isoTime, formatter)
val now = OffsetDateTime.now(ZoneId.systemDefault())
val duration = Duration.between(dateTime, now)
when {
duration.toMinutes() < 1 -> "baru saja"
duration.toMinutes() < 60 -> "${duration.toMinutes()} menit yang lalu"
duration.toHours() < 24 -> "${duration.toHours()} jam yang lalu"
duration.toDays() < 7 -> "${duration.toDays()} hari yang lalu"
duration.toDays() < 30 -> "${duration.toDays() / 7} minggu yang lalu"
duration.toDays() < 365 -> "${duration.toDays() / 30} bulan yang lalu"
else -> "${duration.toDays() / 365} tahun yang lalu"
}
} catch (e: Exception) {
"Format tidak valid"
}
}

View File

@ -0,0 +1,13 @@
package com.syaroful.agrilinkvocpro.control_feature.data.model
data class ActuatorHistoryResponse(
val `data`: List<ActuatorLog?>?,
val lastPage: Int?,
val message: String?,
val nextPage: Any?,
val page: Int?,
val perPage: Int?,
val previousPage: Any?,
val statusCode: Int?,
val total: Int?
)

View File

@ -1,12 +1,12 @@
package com.syaroful.agrilinkvocpro.control_feature.navigation package com.syaroful.agrilinkvocpro.control_feature.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.syaroful.agrilinkvocpro.control_feature.presentation.control.ControlActuatorScreen import com.syaroful.agrilinkvocpro.control_feature.presentation.control.ControlActuatorScreen
import com.syaroful.agrilinkvocpro.control_feature.presentation.control.ControlViewModel import com.syaroful.agrilinkvocpro.control_feature.presentation.control.ControlViewModel
import com.syaroful.agrilinkvocpro.control_feature.presentation.history.ControlHistoryScreen
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
@ -15,10 +15,12 @@ fun NavGraph(navController: NavHostController) {
NavHost(navController, startDestination = "control_screen") { NavHost(navController, startDestination = "control_screen") {
composable("control_screen") { composable("control_screen") {
ControlActuatorScreen(controlViewModel) ControlActuatorScreen(controlViewModel, navController)
} }
composable("history_control_screen") { composable("control_history") {
Box() {} ControlHistoryScreen(
navController = navController
)
} }
} }
} }

View File

@ -0,0 +1,145 @@
package com.syaroful.agrilinkvocpro.control_feature.presentation.history
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
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.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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.syaroful.agrilinkvocpro.control_feature.core.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.core.utils.getRelativeTime
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorHistoryResponse
import com.syaroful.agrilinkvocpro.core.components.DefaultErrorComponent
import com.syaroful.agrilinkvocpro.presentation.theme.MainGreen
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControlHistoryScreen(
controlHistoryViewModel: ControlHistoryViewModel = koinViewModel(),
navController: NavController
) {
val historyState by controlHistoryViewModel.historyState.collectAsState()
val isRefreshing = remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text("Riwayat Kontrol Aktuator", style = MaterialTheme.typography.titleMedium)
}
},
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
)
},
) { innerPadding ->
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
isRefreshing.value = true
controlHistoryViewModel.getActuatorHistory()
},
) {
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (historyState) {
is ControlState.Error -> {
isRefreshing.value = false
item {
DefaultErrorComponent(
message = (historyState as ControlState.Error).message,
label = "Oops!",
)
}
}
is ControlState.Success -> {
isRefreshing.value = false
val data =
(historyState as ControlState.Success<ActuatorHistoryResponse>).data?.data
if (!data.isNullOrEmpty()) {
val reversedData = data.reversed()
items(reversedData) { historyData ->
ListItem(
leadingContent = {
Text("${historyData?.actuatorId}")
},
headlineContent = {
val state =
if (historyData?.action == "ON") "Diaktifkan oleh " else "Dinonaktifkan oleh "
Text(text = "$state ${historyData?.triggeredBy}")
},
supportingContent = {
Text(
text = getRelativeTime(historyData?.createdAt.toString())
)
},
trailingContent = {
Text("${historyData?.action}",
color = if (historyData?.action == "ON") MainGreen else Color.Red
)
},
shadowElevation = 2.dp
)
}
} else {
item {
Text("Tidak ada data riwayat kontrol")
}
}
}
is ControlState.Loading -> {
item {
CircularProgressIndicator(
color = MainGreen
)
}
}
else -> {}
}
}
}
}
}

View File

@ -0,0 +1,56 @@
package com.syaroful.agrilinkvocpro.control_feature.presentation.history
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.syaroful.agrilinkvocpro.control_feature.core.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorHistoryResponse
import com.syaroful.agrilinkvocpro.control_feature.data.repository.ControlRepository
import com.syaroful.agrilinkvocpro.core.utils.extention.mapToUserFriendlyError
import com.syaroful.agrilinkvocpro.data.UserPreferences
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
private const val TAG = "ControlHistoryViewModel"
class ControlHistoryViewModel(
private val userPreferences: UserPreferences,
private val repository: ControlRepository
) : ViewModel() {
private val _historyState =
MutableStateFlow<ControlState<ActuatorHistoryResponse>>(ControlState.Idle)
val historyState: MutableStateFlow<ControlState<ActuatorHistoryResponse>> = _historyState
init {
getActuatorHistory()
}
fun getActuatorHistory() {
_historyState.value = ControlState.Loading
viewModelScope.launch {
val token = userPreferences.tokenFlow.first()
val authHeader = "Bearer $token"
try {
delay(500L)
val response = repository.getActuatorHistory(authHeader = authHeader)
if (response.isSuccessful) {
val data = response.body()
_historyState.value = ControlState.Success(data)
Log.d(TAG, "Successfully get Actuator History ${response.body()}")
} else {
val errorBody = response.errorBody()?.string()
val errorMessage = errorBody ?: "Error ${response.code()}"
_historyState.value = ControlState.Error(errorMessage)
}
} catch (e: Exception) {
val errorMessage = mapToUserFriendlyError(e)
_historyState.value = ControlState.Error(errorMessage)
}
}
}
}