Compose State Recomposition Issue
تم الإنشاء في: ١٧ يناير ٢٠٢٥
تم الإنشاء في: ١٧ يناير ٢٠٢٥
@Composable
fun ISSMComposableStatistics(
navController: NavController? = null,
premiseViewModel: PremiseViewmodel,
tariffViewModel: TariffViewModel
) {
textval context = LocalContext.current val view = LocalView.current val window = (view.context as Activity).window window.statusBarColor = White.toArgb() val scrollState = rememberScrollState() WindowInsetsControllerCompat(window, view).isAppearanceLightStatusBars = true val statisticsViewModel: ISSMStatisticsViewModel = hiltViewModel() val navigationViewModel: ISSMNavigationViewModel = hiltViewModel() val connectivityViewModel: ConnectivityViewModel = hiltViewModel() val dbOrderViewModel: DBOrderViewModel = hiltViewModel() LaunchedEffect(Unit) { window?.let { WindowCompat.setDecorFitsSystemWindows(it, true) } resetMissingConnectionStateState() statisticsViewModel.observeConnectivityState( connectivityViewModel.isOnline, connectivityViewModel::isNetworkAvailable ) statisticsViewModel.getBothStats(_id) } DisposableEffect(Unit) { onDispose { statisticsViewModel.cleanup() } } val state = statisticsViewModel.combinedStats.observeAsState() when (val statsState = state.value) { is CombinedStatisticsUIState.Loading -> { ShimmerAnimationStatistics() } is CombinedStatisticsUIState.Success -> { Timber.tag("ListApi").d("loadPremise & loadTariffs called from Stats Screen") premiseViewModel.loadPremise() tariffViewModel.loadTariffs() Column( modifier = Modifier .fillMaxSize() .background(White) ) { Box( modifier = Modifier .fillMaxSize() .background(color = Color.Transparent) ){ Column(modifier = Modifier.padding(16.dp).verticalScroll(scrollState)) { UserInfoCard( connectivityViewModel = connectivityViewModel ) var selectedTabIndex by remember { mutableStateOf(0) } val tabs = listOf("Current Statistics", "Overall Statistics") Column { TabRow( selectedTabIndex = selectedTabIndex, containerColor = White, contentColor = Color(0xFFE91E63), // Pink color from the image indicator = { tabPositions -> SecondaryIndicator( modifier = Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), height = 2.dp, color = Color(0xFFE91E63) ) } ) { tabs.forEachIndexed { index, title -> Tab( selected = selectedTabIndex == index, onClick = { selectedTabIndex = index }, text = { Text( text = title, color = if (selectedTabIndex == index) Color(0xFFE91E63) else Color.Gray ) } ) } } when (selectedTabIndex) { 0 -> { val totalPending = statsState.currentStats.pendingInitialReadOrders + statsState.currentStats.pendingCheckReadOrders TotalOrdersCard( totalOrders = statsState.currentStats.totalOrders, initialReadOrders = statsState.currentStats.pendingInitialReadOrders, checkReadOrders = statsState.currentStats.pendingCheckReadOrders, pendingOrders = totalPending, employeeDoneOrders = statsState.currentStats.employeeDone, postedToPortalOrders = statsState.currentStats.postedToPortal, ) Spacer(modifier = Modifier.height(10.dp)) Column( modifier = Modifier .fillMaxWidth() .padding(start = 16.dp), horizontalAlignment = Alignment.Start ) { Text( text = "Status", fontSize = 18.sp, fontFamily = Assistant, fontWeight = FontWeight.SemiBold, color = Color.Black ) } // Status Card val validProgress = if (statsState.currentStats.progress == "NaN") "0.0" else statsState.currentStats.progress StatusCard(progress = validProgress) Spacer(modifier = Modifier.height(8.dp)) // View Order Button Box( modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { ISSM_Button( heading = "View Current Orders", onClicked = { navigationViewModel.navigateToScreen( navController = navController, route = NavScreen.ScreenSubOffice.route ) } ) } } 1 -> { val totalPending = statsState.overallStats.pendingInitialReadOrders + statsState.overallStats.pendingCheckReadOrders TotalOrdersCard( totalOrders = statsState.overallStats.totalOrders, initialReadOrders = statsState.overallStats.pendingInitialReadOrders, checkReadOrders = statsState.overallStats.pendingCheckReadOrders, pendingOrders = totalPending, employeeDoneOrders = statsState.overallStats.employeeDone, postedToPortalOrders = statsState.overallStats.postedToPortal, picturesTaken = statsState.overallStats.pictureTaken, ) } } } } Box( modifier = Modifier.align(Alignment.TopStart) ) { DrawerButton(navController = navController) } } } } is CombinedStatisticsUIState.Error -> { ISSM_Logout_Dialog( onDismissRequest = { }, onConfirmation = { PreferencesManager.removeAccessToken(context = context) PreferencesManager.removeEmployeeIdToken(context = context) navigationViewModel.navigateToScreen( navController = navController, route = NavScreen.ScreenLogin.route, popUpTo = null, inclusive = true, launchSingleTop = true, popUpToId = 0 ) dbOrderViewModel.deleteAllData() val activity = context as? MainActivity activity?.checkAndStopLocationServiceIfLoggedOut() }, onRetry = { statisticsViewModel.getBothStats(_id) }, icon = 0, type = "Logout", heading = statsState.error, subHeading = "Try logging in again!" ) } null -> Text("Waiting for Data...") }
}
@Composable
fun TotalOrdersCard(
totalOrders: Int,
initialReadOrders: Int,
checkReadOrders: Int,
pendingOrders: Int,
employeeDoneOrders: Int,
postedToPortalOrders: Int,
picturesTaken: Int? = null,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Total Orders Count
Text(
text = totalOrders.toString(),
fontSize = 42.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.SansSerif,
color = Color(0xFFD2386C) // Pink Color
)
Text(
text = "Total Orders",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.SansSerif,
color = Color.Gray
)
textSpacer(modifier = Modifier.height(22.dp)) // Reads Row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) // Adjust spacing ) { Column( modifier = Modifier .background(color = Color(0xFFFBFBFB), shape = RoundedCornerShape(8.dp)) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "Initial Read", fontSize = 14.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily.SansSerif, color = Color.Gray ) Text( text = initialReadOrders.toString(), fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.SansSerif, color = Color.Black ) } Column( modifier = Modifier .background(color = Color(0xFFFBFBFB), shape = RoundedCornerShape(8.dp)) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = "Check Read", fontSize = 14.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily.SansSerif, color = Color.Gray ) Text( text = checkReadOrders.toString(), fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.SansSerif, color = Color.Black ) } } Spacer(modifier = Modifier.height(16.dp)) // Additional Info Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(9.dp) ) { DetailRow(label = "Pending", value = pendingOrders.toString()) DetailRow(label = "Employee Done", value = employeeDoneOrders.toString()) DetailRow(label = "Posted to portal", value = postedToPortalOrders.toString()) if(picturesTaken != null){ DetailRow(label = "Pictures Taken", value = picturesTaken.toString()) } } } }
}
@Composable
fun DetailRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.SansSerif,
color = Color.Gray
)
Text(
text = value,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.SansSerif,
color = Color.Black
)
}
}
@Composable
fun StatusCard(
progress: String
) {
textCard( modifier = Modifier .fillMaxWidth() .padding(16.dp), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = Color.White), elevation = CardDefaults.cardElevation(2.dp) ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(14.dp)) Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CircularProgressBarWithPercentage( progress = progress.toDouble(), // Replace with your dynamic value circleColor = PrimaryColor, trackColor = Color.LightGray, textColor = Color.Black ) } Spacer(modifier = Modifier.height(18.dp)) Text( text = "You have achieved", fontSize = 16.sp, fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, color = Color.Black ) Text( text = "${progress}% of your goal today", fontSize = 16.sp, fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.SemiBold, color = Color.Black ) } }
}
@Composable
fun UserInfoCard(
connectivityViewModel: ConnectivityViewModel
) {
val userDetails = ISSMSingleton.getLoggedUser()
val calendar = Calendar.getInstance()
val currentDay = calendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.LONG, Locale.getDefault()) ?: "Unknown"
val formatter = SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
val formattedDate = formatter.format(calendar.time)
textval isOnline by connectivityViewModel.isOnline.collectAsState() val loggedUser = ISSMSingleton.getLoggedUser() Row( modifier = Modifier .fillMaxWidth() .padding(top = 22.dp, start = 66.dp, bottom = 16.dp, end = 16.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Column( modifier = Modifier.weight(1f) ) { Text( text = userDetails.userFirstName + " " + userDetails.userLastName, fontSize = 24.sp, fontFamily = Assistant, fontWeight = FontWeight.Bold, color = Color.Black ) if (userDetails.userPhone.isNotEmpty()){ Text( text = userDetails.userPhone, fontSize = 16.sp, fontFamily = Assistant, color = Color.Gray ) } Text( text = "$currentDay - $formattedDate", fontSize = 12.sp, fontFamily = Assistant, color = Color.Gray ) } Box( modifier = Modifier.size(50.dp) // Size of the entire container ) { // Circular user image GlideImage( imageRes = loggedUser.userImage, contentDescription = Const_Profile_Image_Desc, modifier = Modifier .size(50.dp) .clip(CircleShape) // Clip to the circle shape .background(Color.Gray) ) // Online/Offline indicator Box( modifier = Modifier .size(12.dp) // Size of the indicator .clip(CircleShape) // Circular shape .background(if (isOnline) Color.Green else Color.Red) // Dynamic color .align(Alignment.TopEnd) // Position at the top-right corner .padding(4.dp) // Optional: Adjust padding as needed ) } }
}
@Composable
fun CircularProgressBarWithPercentage(
progress: Double,
circleColor: Color = Color(0xFF6200EE), // Customize as needed
trackColor: Color = Color(0xFFEEEEEE),
textColor: Color = Color.Black,
modifier: Modifier = Modifier.size(100.dp) // Default size of the circle
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
// Track (background circle)
Canvas(modifier = Modifier.fillMaxSize()) {
drawArc(
color = trackColor,
startAngle = -90f, // Start from top
sweepAngle = 360f, // Full circle
useCenter = false,
style = Stroke(width = 12.dp.toPx(), cap = StrokeCap.Round)
)
}
text// Progress (foreground circle) Canvas(modifier = Modifier.fillMaxSize()) { drawArc( color = circleColor, startAngle = -90f, // Start from top sweepAngle = (progress / 100 * 360).toFloat(), // Calculate the angle based on progress useCenter = false, style = Stroke(width = 12.dp.toPx(), cap = StrokeCap.Round) ) } // Percentage Text Text( text = "${progress}%", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = textColor ) }
}
the logcat shows
2025-01-17 22:15:29.904 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise & loadTariffs called from Stats Screen
2025-01-17 22:15:29.904 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise called
2025-01-17 22:15:29.907 28058-28058 ListApi ai.issm.issm_fsm_app D loadTariffs called
2025-01-17 22:15:30.110 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise & loadTariffs called from Stats Screen
2025-01-17 22:15:30.110 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise called
2025-01-17 22:15:30.113 28058-28058 ListApi ai.issm.issm_fsm_app D loadTariffs called
2025-01-17 22:15:32.552 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise & loadTariffs called from Stats Screen
2025-01-17 22:15:32.552 28058-28058 ListApi ai.issm.issm_fsm_app D loadPremise called
2025-01-17 22:15:32.556 28058-28058 ListApi ai.issm.issm_fsm_app D loadTariffs called
meaning the code
textpremiseViewModel.loadPremise() tariffViewModel.loadTariffs()
gets calls again although the CombinedStatisticsUIState.Success happens once. I have noticed that when i perform an action inside the statistics screen eg click on the View Current Orders button to naviagte to the next screen, then it calls the
textpremiseViewModel.loadPremise() tariffViewModel.loadTariffs()
again
In Jetpack Compose, any code inside your when
block (or more generally, any composable) can be re-run on every recomposition. Whenever you navigate away and come back, or trigger a state change that causes recomposition, the CombinedStatisticsUIState.Success -> { ... }
block may re-run and therefore call:
kotlinpremiseViewModel.loadPremise() tariffViewModel.loadTariffs()
multiple times.
Recomposition: Jetpack Compose uses a declarative UI paradigm. Whenever a state change is observed, the composables that read that state get recomposed. If your code is placed inside a composable (or a branch of a when
statement in a composable) without guards, it will be re-invoked each time that composable is recomposed.
Navigation triggers: If you navigate to another screen and back, you can also trigger a recomposition of the previous screen. In your logs, it looks like each time you navigate (or do some action that changes UI state), the composable is recomposed, and your when
block is called again, hence the repeated logs.
Move side-effect calls into a LaunchedEffect
:
The most direct Compose-friendly solution is to move calls like premiseViewModel.loadPremise()
and tariffViewModel.loadTariffs()
into a side-effect that runs once when the CombinedStatisticsUIState.Success
state is first observed.
For example, if your combined stats have a stable id
or something you can key off (or you just want to run once on success), you could do something like:
kotlinwhen (val statsState = state.value) { is CombinedStatisticsUIState.Success -> { // Moved these calls into a LaunchedEffect so that // they don't get triggered on every recomposition. LaunchedEffect(Unit) { Timber.tag("ListApi").d("loadPremise & loadTariffs called from Stats Screen") premiseViewModel.loadPremise() tariffViewModel.loadTariffs() } Column(/* ... UI ... */) { // ... } } // ... }
Since LaunchedEffect(Unit)
has a stable key (the same value of Unit
each time), it will run exactly once for that part of the composition's lifecycle. If you only want it to re-run when your statsState
changes, you could do:
kotlinwhen (val statsState = state.value) { is CombinedStatisticsUIState.Success -> { LaunchedEffect(statsState) { premiseViewModel.loadPremise() tariffViewModel.loadTariffs() } // ... } // ... }
This will run whenever statsState
changes, but not on any other re-compositions triggered by local state changes (like clicking a button).
Guard with a boolean flag in the ViewModel:
Another approach is to have some boolean in your premiseViewModel
or tariffViewModel
that tracks whether the data has already been loaded. For example, your premiseViewModel
could have something like:
kotlinprivate var premiseLoaded = false fun loadPremiseIfNeeded() { if (!premiseLoaded) { premiseLoaded = true loadPremise() // your existing logic } }
Then in the composable:
kotlinwhen (val statsState = state.value) { is CombinedStatisticsUIState.Success -> { premiseViewModel.loadPremiseIfNeeded() tariffViewModel.loadTariffsIfNeeded() // ... } }
This ensures each is only triggered once, no matter how many recompositions occur.
Dispatch from the ViewModel:
Instead of making the call from the UI, you could design your ViewModel to load premises and tariffs automatically once the stats are successfully fetched (i.e., chain your asynchronous events). In other words, once you get the success result in your ISSMStatisticsViewModel
, you can tell your premiseViewModel
and tariffViewModel
to fetch their data, removing the need for the UI to call them at all. This can keep your UI stateless and side-effect-free.
Any of these solutions will eliminate the repeated calls you see in Logcat and ensure loadPremise()
and loadTariffs()
only happen once unless you explicitly want them to re-run.