feat: implement actuator control with Koin DI and API integration

This commit is contained in:
Cutiful 2025-06-29 07:53:10 +07:00
parent 287d5774e8
commit f48a96c96c
18 changed files with 649 additions and 259 deletions

View File

@ -1,20 +1,37 @@
package com.syaroful.agrilinkvocpro.control_feature package com.syaroful.agrilinkvocpro.control_feature
import android.os.Bundle import android.os.Bundle
import android.util.Log
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.control_feature.page.ControlActuatorScreen import androidx.navigation.compose.rememberNavController
import com.syaroful.agrilinkvocpro.control_feature.di.controlModule
import com.syaroful.agrilinkvocpro.control_feature.di.networkModule
import com.syaroful.agrilinkvocpro.control_feature.di.viewModelModule
import com.syaroful.agrilinkvocpro.control_feature.navigation.NavGraph
import com.syaroful.agrilinkvocpro.control_feature.ui.theme.AgrilinkVocproTheme import com.syaroful.agrilinkvocpro.control_feature.ui.theme.AgrilinkVocproTheme
import org.koin.core.context.loadKoinModules
import org.koin.core.context.unloadKoinModules
class ControlActuatorActivity : ComponentActivity() { class ControlActuatorActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// load Module
loadKoinModules(listOf(controlModule, networkModule, viewModelModule))
Log.d("KOIN_DEBUG", "✅ controlModule loaded, yeay 🤗")
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
AgrilinkVocproTheme { AgrilinkVocproTheme {
ControlActuatorScreen() val navController = rememberNavController()
NavGraph(navController)
} }
} }
} }
override fun onDestroy() {
super.onDestroy()
unloadKoinModules(listOf(controlModule, networkModule, viewModelModule))
Log.d("KOIN_DEBUG", "🧹 controlModule unloaded")
}
} }

View File

@ -1,4 +1,4 @@
package com.syaroful.agrilinkvocpro.control_feature.components package com.syaroful.agrilinkvocpro.control_feature.core.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@ -38,6 +39,7 @@ fun ControlCard(
iconRes: Int, iconRes: Int,
label: String, label: String,
isOn: Boolean, isOn: Boolean,
isLoading: Boolean,
onToggle: (Boolean) -> Unit onToggle: (Boolean) -> Unit
) { ) {
val backgroundColor = if (isOn) DarkGreen else MaterialTheme.colorScheme.surfaceContainer val backgroundColor = if (isOn) DarkGreen else MaterialTheme.colorScheme.surfaceContainer
@ -75,6 +77,9 @@ fun ControlCard(
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
if (isLoading) {
CircularProgressIndicator(color = MainGreen)
} else {
Switch( Switch(
checked = isOn, checked = isOn,
onCheckedChange = onToggle, onCheckedChange = onToggle,
@ -87,6 +92,7 @@ fun ControlCard(
) )
) )
} }
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))

View File

@ -0,0 +1,102 @@
package com.syaroful.agrilinkvocpro.control_feature.core.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.syaroful.agrilinkvocpro.control_feature.R
@Composable
fun DisableControlCard(
label: String,
isOn: Boolean,
iconRes: Int = R.drawable.ic_valve,
backgroundColor: Color = Color.Gray.copy(alpha = 0.1f),
iconTint: Color = Color.Gray,
textColor: Color = Color.Gray,
) {
Column(
modifier = Modifier
.padding(vertical = 8.dp)
.clip(RoundedCornerShape(12.dp))
.border(1.dp, backgroundColor.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.background(backgroundColor)
.padding(16.dp)
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.size(32.dp)
.background(
color = iconTint.copy(alpha = 0.1f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(20.dp)
)
}
// Switch(
// checked = isOn,
// onCheckedChange = onToggle,
// colors = SwitchDefaults.colors(
// checkedThumbColor = Color.LightGray,
// uncheckedThumbColor = Color.LightGray,
// uncheckedBorderColor = trackColor,
// checkedTrackColor = backgroundColor,
// checkedBorderColor = trackColor,
// uncheckedTrackColor = trackColor
// )
// )
}
Spacer(modifier = Modifier.height(16.dp))
Text(label, color = textColor)
Text(
if (isOn) "On" else "Off",
color = Color.Gray,
fontWeight = FontWeight.Bold
)
}
}
@Preview
@Composable
fun DisableControlCardPreview() {
DisableControlCard(
label = "Water Valve",
isOn = false,
)
}

View File

@ -0,0 +1,8 @@
package com.syaroful.agrilinkvocpro.control_feature.core.state
sealed class ControlState<out T> {
data object Idle : ControlState<Nothing>()
data object Loading : ControlState<Nothing>()
data class Success<T>(val data: T?) : ControlState<T>()
data class Error(val message: String) : ControlState<Nothing>()
}

View File

@ -0,0 +1,13 @@
package com.syaroful.agrilinkvocpro.control_feature.data.model
data class ActuatorStatusResponse(
val `data`: List<ActuatorStatus?>?,
val message: String?
)
data class ActuatorStatus(
val currentStatus: String?,
val lastChangedAt: String?,
val lastTriggeredBy: String?,
val name: String?
)

View File

@ -0,0 +1,7 @@
package com.syaroful.agrilinkvocpro.control_feature.data.model
enum class ActuatorType(val displayName: String) {
WATER("Water Valve"),
NUTRIENT("Nutrient Valve"),
PUMP("Pump")
}

View File

@ -0,0 +1,14 @@
package com.syaroful.agrilinkvocpro.control_feature.data.model
data class ControlLogResponse(
val log: Log?,
val message: String?
)
data class Log(
val action: String?,
val actuatorId: Int?,
val createdAt: String?,
val id: Int?,
val triggeredBy: String?
)

View File

@ -0,0 +1,34 @@
package com.syaroful.agrilinkvocpro.control_feature.data.network
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorStatusResponse
import com.syaroful.agrilinkvocpro.control_feature.data.model.ControlLogResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
interface ControlService {
@GET("api/actuators/status")
suspend fun getActuatorStatus(
@Header("Authorization") authHeader: String,
): Response<ActuatorStatusResponse>
@POST("api/actuators/water-valve/control")
suspend fun controlWaterValve(
@Header("Authorization") authHeader: String,
@Body action: String,
): Response<ControlLogResponse>
@POST("api/actuators/nutrient-valve/control")
suspend fun controlNutrientValve(
@Header("Authorization") authHeader: String,
@Body action: String,
): Response<ControlLogResponse>
@POST("api/actuators/pump/control")
suspend fun controlPump(
@Header("Authorization") authHeader: String,
@Body action: String,
): Response<ControlLogResponse>
}

View File

@ -0,0 +1,28 @@
package com.syaroful.agrilinkvocpro.control_feature.data.repository
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorStatusResponse
import com.syaroful.agrilinkvocpro.control_feature.data.model.ControlLogResponse
import com.syaroful.agrilinkvocpro.control_feature.data.network.ControlService
import retrofit2.Response
class ControlRepository(
private val controlService: ControlService
) {
suspend fun controlWaterValve(authHeader: String, action: String): Response<ControlLogResponse>{
return controlService.controlWaterValve(authHeader, action)
}
suspend fun controlNutrientValve(authHeader: String, action: String): Response<ControlLogResponse>{
return controlService.controlNutrientValve(authHeader, action)
}
suspend fun controlPump(authHeader: String, action: String): Response<ControlLogResponse>{
return controlService.controlPump(authHeader, action)
}
suspend fun getActuatorStatus(authHeader: String): Response<ActuatorStatusResponse> {
return controlService.getActuatorStatus(authHeader)
}
}

View File

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

View File

@ -0,0 +1,27 @@
package com.syaroful.agrilinkvocpro.control_feature.di
import com.syaroful.agrilinkvocpro.control_feature.data.network.ControlService
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(3, TimeUnit.SECONDS)
.build()
}
single {
Retrofit.Builder()
.baseUrl("http://labai.polinema.ac.id:3042/")
.client(get())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
single<ControlService> { get<Retrofit>().create(ControlService::class.java) }
}

View File

@ -0,0 +1,9 @@
package com.syaroful.agrilinkvocpro.control_feature.di
import com.syaroful.agrilinkvocpro.control_feature.presentation.control.ControlViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val viewModelModule = module {
viewModel { ControlViewModel(get(), get()) }
}

View File

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

View File

@ -1,114 +0,0 @@
package com.syaroful.agrilinkvocpro.control_feature.page
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.syaroful.agrilinkvocpro.control_feature.R
import com.syaroful.agrilinkvocpro.control_feature.components.ControlCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControlActuatorScreen() {
Scaffold(
topBar = {
TopAppBar(
title = {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text("Control Actuator", style = MaterialTheme.typography.titleMedium)
}
},
navigationIcon = {
IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { }) {
Icon(Icons.Filled.Info, contentDescription = "History")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Text("Penyiraman Air", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
ControlGrid(
iconRes = R.drawable.ic_valve,
items = List(4) { index -> "Bet ${index + 1}" }
)
Spacer(modifier = Modifier.height(16.dp))
Text("Penyiraman Pupuk", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
ControlGrid(
iconRes = R.drawable.ic_leaf,
items = List(4) { index -> "Bet ${index + 1}" }
)
}
}
}
@Composable
fun ControlGrid(iconRes: Int, items: List<String>) {
val states = remember { items.map { mutableStateOf(false) } }
val gridHeight = (items.size / 2.0).coerceAtLeast(1.0) * 170
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxWidth().height(gridHeight.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(items.size) { index ->
ControlCard(
iconRes = iconRes,
label = items[index],
isOn = states[index].value,
onToggle = { states[index].value = it }
)
}
}
}
@Preview
@Composable
fun ControlActuatorScreenPreview() {
ControlActuatorScreen()
}

View File

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

View File

@ -0,0 +1,155 @@
package com.syaroful.agrilinkvocpro.control_feature.presentation.control
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
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.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.unit.dp
import com.syaroful.agrilinkvocpro.control_feature.R
import com.syaroful.agrilinkvocpro.control_feature.core.components.ControlCard
import com.syaroful.agrilinkvocpro.control_feature.core.components.DisableControlCard
import com.syaroful.agrilinkvocpro.control_feature.core.state.ControlState
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorType
import com.syaroful.agrilinkvocpro.core.placeholder.shimmerEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ControlActuatorScreen(
viewModel: ControlViewModel
) {
val allActuators by viewModel.allActuatorState.collectAsState()
val waterValveStatus by viewModel.waterValveStatus.collectAsState()
val nutritionValveStatus by viewModel.nutritionValveStatus.collectAsState()
val pumpStatus by viewModel.pumpStatus.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val isRefreshing = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.getActuatorStatus()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Text("Control Actuator", style = MaterialTheme.typography.titleMedium)
}
},
navigationIcon = {
IconButton(onClick = { }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { }) {
Icon(Icons.Filled.Info, contentDescription = "History")
}
}
)
}
) { innerPadding ->
PullToRefreshBox(
isRefreshing = isRefreshing.value,
onRefresh = {
isRefreshing.value = true
viewModel.getActuatorStatus()
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(innerPadding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Daftar Actuator", style = MaterialTheme.typography.titleMedium)
// ControlGrid(
// iconRes = R.drawable.ic_valve,
// items = List(4) { index -> "Bet ${index + 1}" }
// )
when (allActuators) {
is ControlState.Loading -> {
isRefreshing.value = false
Box(
modifier = Modifier
.fillMaxWidth()
.height(96.dp)
.shimmerEffect()
)
}
is ControlState.Error -> {
isRefreshing.value = false
DisableControlCard(
label = "Control Failed!",
isOn = false,
)
}
ControlState.Idle -> {
isRefreshing.value = false
}
is ControlState.Success<*> -> {
isRefreshing.value = false
ControlCard(
iconRes = R.drawable.ic_valve,
label = "Water Valve",
isOn = waterValveStatus,
isLoading = isLoading,
onToggle = { viewModel.controlActuator(ActuatorType.WATER, !waterValveStatus) }
)
ControlCard(
iconRes = R.drawable.ic_leaf,
label = "Nutrient Valve",
isOn = nutritionValveStatus,
isLoading = isLoading,
onToggle = { viewModel.controlActuator(ActuatorType.NUTRIENT, !nutritionValveStatus) }
)
ControlCard(
iconRes = R.drawable.ic_valve,
label = "Pump",
isOn = pumpStatus,
isLoading = isLoading,
onToggle = { viewModel.controlActuator(ActuatorType.PUMP, !pumpStatus) }
)
}
}
}
}
}
}

View File

@ -0,0 +1,109 @@
package com.syaroful.agrilinkvocpro.control_feature.presentation.control
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.ActuatorStatusResponse
import com.syaroful.agrilinkvocpro.control_feature.data.model.ActuatorType
import com.syaroful.agrilinkvocpro.control_feature.data.repository.ControlRepository
import com.syaroful.agrilinkvocpro.data.UserPreferences
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
private const val TAG = "ControlViewModel"
class ControlViewModel(
private val userPreferences: UserPreferences,
private val repository: ControlRepository
) : ViewModel() {
private val _allActuatorState =
MutableStateFlow<ControlState<ActuatorStatusResponse>>(ControlState.Idle)
val allActuatorState: StateFlow<ControlState<ActuatorStatusResponse>> = _allActuatorState
private val _waterValveStatus = MutableStateFlow<Boolean>(false)
val waterValveStatus: StateFlow<Boolean> = _waterValveStatus
private val _nutritionValveStatus = MutableStateFlow<Boolean>(false)
val nutritionValveStatus: StateFlow<Boolean> = _nutritionValveStatus
private val _pumpStatus = MutableStateFlow<Boolean>(false)
val pumpStatus: StateFlow<Boolean> = _pumpStatus
private val _isLoading = MutableStateFlow<Boolean>(false)
val isLoading: StateFlow<Boolean> = _isLoading
fun getActuatorStatus() {
_allActuatorState.value = ControlState.Loading
viewModelScope.launch {
val token = userPreferences.tokenFlow.first()
val authHeader = "Bearer $token"
try {
delay(500L)
val response = repository.getActuatorStatus(authHeader)
if (response.isSuccessful) {
val data = response.body()
_allActuatorState.value = ControlState.Success(data)
data?.data?.forEach { actuator ->
val isOn = actuator?.currentStatus == "ON"
when (actuator?.name) {
ActuatorType.WATER.displayName -> _waterValveStatus.value = isOn
ActuatorType.NUTRIENT.displayName -> _nutritionValveStatus.value = isOn
ActuatorType.PUMP.displayName -> _pumpStatus.value = isOn
}
}
} else {
val errorBody = response.errorBody()?.string()
val errorMessage = errorBody ?: "Error ${response.code()}"
_allActuatorState.value = ControlState.Error(errorMessage)
}
} catch (e: Exception) {
_allActuatorState.value = ControlState.Error(e.message ?: "Unknown error")
}
}
}
fun controlActuator(actuator: ActuatorType, state: Boolean) {
_isLoading.value = true
viewModelScope.launch {
val token = userPreferences.tokenFlow.first()
val authHeader = "Bearer $token"
val stringState = if (state) "ON" else "OFF"
try {
delay(500L)
val response = when (actuator) {
ActuatorType.WATER -> repository.controlWaterValve(authHeader, stringState)
ActuatorType.NUTRIENT -> repository.controlNutrientValve(authHeader, stringState)
ActuatorType.PUMP -> repository.controlPump(authHeader, stringState)
}
if (response.isSuccessful) {
val isOn = response.body()?.log?.action == "ON"
updateActuatorStatus(actuator, isOn)
Log.d(TAG, "Successfully controlled ${actuator.displayName}")
} else {
val errorBody = response.errorBody()?.string()
Log.e(TAG, "Error controlling ${actuator.displayName}: $errorBody")
}
} catch (e: Exception) {
Log.e(TAG, "Exception while controlling ${actuator.displayName}: ${e.message}")
} finally {
_isLoading.value = false
}
}
}
private fun updateActuatorStatus(actuator: ActuatorType, isOn: Boolean) {
when (actuator) {
ActuatorType.WATER -> _waterValveStatus.value = isOn
ActuatorType.NUTRIENT -> _nutritionValveStatus.value = isOn
ActuatorType.PUMP -> _pumpStatus.value = isOn
}
}
}

View File

@ -1,57 +0,0 @@
package com.syaroful.agrilinkvocpro.control_feature.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.firebase.database.DataSnapshot
import com.google.firebase.database.DatabaseError
import com.google.firebase.database.ValueEventListener
import com.google.firebase.database.ktx.database
import com.google.firebase.ktx.Firebase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
private const val TAG = "ControlViewModel"
class ControlViewModel : ViewModel() {
private val _relayState = MutableStateFlow(false)
val relayState: StateFlow<Boolean> = _relayState
private val database = Firebase.database
private val relayRef = database.getReference("relay1/state")
init {
observeRelayState()
}
private fun observeRelayState() {
relayRef.get()
.addOnSuccessListener { snapshot ->
val value = snapshot.getValue(Int::class.java) ?: 0
_relayState.value = (value == 1)
}.addOnFailureListener{
Log.e(TAG, "Failed to fetch initial relay state", it)
}
relayRef.addValueEventListener(object : ValueEventListener {
override fun onDataChange(snapshot: DataSnapshot) {
val value = snapshot.getValue(Int::class.java) ?: 0
_relayState.value = (value == 1)
}
override fun onCancelled(error: DatabaseError) {
Log.e(TAG, "Failed to observe relay state", error.toException())
}
})
}
fun setRelayState(isOn: Boolean) {
viewModelScope.launch {
relayRef.setValue(if (isOn) 1 else 0)
.addOnFailureListener {
Log.e(TAG, "Failed to set relay state", it)
}
}
}
}