fix: share ConfigViewModel via nested NavGraph to prevent infinite spinner

List and Detail screens were creating separate ViewModel instances, causing
a race condition where Detail's schema was always null. Using a nested
navigation graph scopes a single ViewModel to the parent back stack entry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KoheiYamashita 2026-02-27 18:19:36 +09:00
parent 790cd6a411
commit 86550e0ab6
4 changed files with 38 additions and 20 deletions

View file

@ -9,18 +9,22 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.runtime.remember
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import io.clawdroid.backend.config.ConfigSectionDetailScreen import io.clawdroid.backend.config.ConfigSectionDetailScreen
import io.clawdroid.backend.config.ConfigSectionListScreen import io.clawdroid.backend.config.ConfigSectionListScreen
import io.clawdroid.backend.config.ConfigViewModel
import io.clawdroid.core.ui.theme.ClawDroidTheme import io.clawdroid.core.ui.theme.ClawDroidTheme
import io.clawdroid.feature.chat.screen.ChatScreen import io.clawdroid.feature.chat.screen.ChatScreen
import io.clawdroid.feature.chat.screen.SettingsScreen import io.clawdroid.feature.chat.screen.SettingsScreen
import io.clawdroid.navigation.NavRoutes import io.clawdroid.navigation.NavRoutes
import io.clawdroid.settings.AppSettingsScreen import io.clawdroid.settings.AppSettingsScreen
import org.koin.androidx.compose.koinViewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -48,23 +52,38 @@ class MainActivity : ComponentActivity() {
onNavigateToAppSettings = { navController.navigate(NavRoutes.APP_SETTINGS) }, onNavigateToAppSettings = { navController.navigate(NavRoutes.APP_SETTINGS) },
) )
} }
composable(NavRoutes.BACKEND_SETTINGS) { navigation(
route = NavRoutes.BACKEND_SETTINGS,
startDestination = NavRoutes.BACKEND_SETTINGS_LIST,
) {
composable(NavRoutes.BACKEND_SETTINGS_LIST) { entry ->
val parentEntry = remember(entry) {
navController.getBackStackEntry(NavRoutes.BACKEND_SETTINGS)
}
val viewModel: ConfigViewModel = koinViewModel(viewModelStoreOwner = parentEntry)
ConfigSectionListScreen( ConfigSectionListScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onSectionSelected = { sectionKey -> onSectionSelected = { sectionKey ->
navController.navigate("backend_settings/$sectionKey") navController.navigate("backend_settings/$sectionKey")
}, },
viewModel = viewModel,
) )
} }
composable( composable(
NavRoutes.BACKEND_SETTINGS_SECTION, NavRoutes.BACKEND_SETTINGS_SECTION,
arguments = listOf(navArgument("sectionKey") { type = NavType.StringType }), arguments = listOf(navArgument("sectionKey") { type = NavType.StringType }),
) { backStackEntry -> ) { entry ->
val parentEntry = remember(entry) {
navController.getBackStackEntry(NavRoutes.BACKEND_SETTINGS)
}
val viewModel: ConfigViewModel = koinViewModel(viewModelStoreOwner = parentEntry)
ConfigSectionDetailScreen( ConfigSectionDetailScreen(
sectionKey = backStackEntry.arguments?.getString("sectionKey") ?: "", sectionKey = entry.arguments?.getString("sectionKey") ?: "",
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
viewModel = viewModel,
) )
} }
}
composable(NavRoutes.APP_SETTINGS) { composable(NavRoutes.APP_SETTINGS) {
AppSettingsScreen( AppSettingsScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },

View file

@ -4,6 +4,7 @@ object NavRoutes {
const val CHAT = "chat" const val CHAT = "chat"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val BACKEND_SETTINGS = "backend_settings" const val BACKEND_SETTINGS = "backend_settings"
const val BACKEND_SETTINGS_LIST = "backend_settings/list"
const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}" const val BACKEND_SETTINGS_SECTION = "backend_settings/{sectionKey}"
const val APP_SETTINGS = "app_settings" const val APP_SETTINGS = "app_settings"
} }

View file

@ -59,14 +59,13 @@ import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary import io.clawdroid.core.ui.theme.TextSecondary
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConfigSectionDetailScreen( fun ConfigSectionDetailScreen(
sectionKey: String, sectionKey: String,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
viewModel: ConfigViewModel = koinViewModel(), viewModel: ConfigViewModel,
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }

View file

@ -42,14 +42,13 @@ import io.clawdroid.core.ui.theme.GlassWhite
import io.clawdroid.core.ui.theme.NeonCyan import io.clawdroid.core.ui.theme.NeonCyan
import io.clawdroid.core.ui.theme.TextPrimary import io.clawdroid.core.ui.theme.TextPrimary
import io.clawdroid.core.ui.theme.TextSecondary import io.clawdroid.core.ui.theme.TextSecondary
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConfigSectionListScreen( fun ConfigSectionListScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onSectionSelected: (sectionKey: String) -> Unit, onSectionSelected: (sectionKey: String) -> Unit,
viewModel: ConfigViewModel = koinViewModel(), viewModel: ConfigViewModel,
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()