feat: clone instance with full details for editing

Clone button on site list now navigates to setup screen pre-filled
with the source instance's URL, API key, and site ID. User can edit
all fields and save as a new instance. API key is resolved from
encrypted storage via the ViewModel, never passed through nav args.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 17:20:28 +01:00
commit 1434f2f842
4 changed files with 47 additions and 4 deletions

View file

@ -70,6 +70,22 @@ fun AppNavHost(
) )
} }
composable(
route = Routes.SETUP_CLONE,
arguments = listOf(
navArgument("cloneInstanceId") { type = NavType.StringType },
navArgument("cloneSiteId") { type = NavType.StringType }
)
) {
SetupScreen(
onInstanceAdded = { instanceId ->
navController.navigate(Routes.siteList(instanceId)) {
popUpTo(0) { inclusive = true }
}
}
)
}
composable( composable(
route = Routes.SITE_LIST, route = Routes.SITE_LIST,
arguments = listOf(navArgument("instanceId") { type = NavType.StringType }) arguments = listOf(navArgument("instanceId") { type = NavType.StringType })
@ -80,6 +96,9 @@ fun AppNavHost(
popUpTo(Routes.SETUP) { inclusive = true } popUpTo(Routes.SETUP) { inclusive = true }
} }
}, },
onCloneSite = { instanceId, siteId ->
navController.navigate(Routes.setupClone(instanceId, siteId))
},
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
} }

View file

@ -8,9 +8,11 @@ package no.naiv.implausibly.ui.navigation
object Routes { object Routes {
const val LOADING = "loading" const val LOADING = "loading"
const val SETUP = "setup" const val SETUP = "setup"
const val SETUP_CLONE = "setup?cloneInstanceId={cloneInstanceId}&cloneSiteId={cloneSiteId}"
const val SITE_LIST = "site_list/{instanceId}" const val SITE_LIST = "site_list/{instanceId}"
const val DASHBOARD = "dashboard/{instanceId}/{siteId}" const val DASHBOARD = "dashboard/{instanceId}/{siteId}"
fun siteList(instanceId: String) = "site_list/$instanceId" fun siteList(instanceId: String) = "site_list/$instanceId"
fun dashboard(instanceId: String, siteId: String) = "dashboard/$instanceId/$siteId" fun dashboard(instanceId: String, siteId: String) = "dashboard/$instanceId/$siteId"
fun setupClone(instanceId: String, siteId: String) = "setup?cloneInstanceId=$instanceId&cloneSiteId=$siteId"
} }

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-only // SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.setup package no.naiv.implausibly.ui.setup
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@ -39,16 +40,39 @@ sealed class TestResult {
@HiltViewModel @HiltViewModel
class SetupViewModel @Inject constructor( class SetupViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val instanceRepository: InstanceRepository, private val instanceRepository: InstanceRepository,
private val siteRepository: SiteRepository, private val siteRepository: SiteRepository,
private val appPreferences: AppPreferences private val appPreferences: AppPreferences
) : ViewModel() { ) : ViewModel() {
private val cloneInstanceId: String? = savedStateHandle["cloneInstanceId"]
private val cloneSiteId: String? = savedStateHandle["cloneSiteId"]
private val _uiState = MutableStateFlow(SetupUiState()) private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow() val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
init { init {
loadExistingInstances() loadExistingInstances()
if (cloneInstanceId != null) {
loadCloneData(cloneInstanceId, cloneSiteId)
}
}
private fun loadCloneData(instanceId: String, siteId: String?) {
viewModelScope.launch {
val instance = instanceRepository.getById(instanceId) ?: return@launch
val apiKey = instanceRepository.getApiKey(instance) ?: ""
_uiState.update {
it.copy(
baseUrl = instance.baseUrl,
apiKey = apiKey,
name = "",
siteId = siteId ?: ""
)
}
}
} }
private fun loadExistingInstances() { private fun loadExistingInstances() {
@ -95,7 +119,6 @@ class SetupViewModel @Inject constructor(
isTesting = false, isTesting = false,
testResult = if (error == null) TestResult.Success else TestResult.Failure(error), testResult = if (error == null) TestResult.Success else TestResult.Failure(error),
name = if (error == null && it.name.isBlank()) { name = if (error == null && it.name.isBlank()) {
// Auto-fill name from site ID if blank
state.siteId state.siteId
} else { } else {
it.name it.name
@ -121,10 +144,8 @@ class SetupViewModel @Inject constructor(
apiKey = apiKey apiKey = apiKey
) )
// Also store the site ID
siteRepository.addSite(state.siteId, instance.id) siteRepository.addSite(state.siteId, instance.id)
// Set as selected
appPreferences.setSelectedInstanceId(instance.id) appPreferences.setSelectedInstanceId(instance.id)
appPreferences.setSelectedSiteId(state.siteId) appPreferences.setSelectedSiteId(state.siteId)

View file

@ -42,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
@Composable @Composable
fun SiteListScreen( fun SiteListScreen(
onSiteSelected: (instanceId: String, siteId: String) -> Unit, onSiteSelected: (instanceId: String, siteId: String) -> Unit,
onCloneSite: (instanceId: String, siteId: String) -> Unit = { _, _ -> },
onBack: () -> Unit, onBack: () -> Unit,
viewModel: SiteListViewModel = hiltViewModel() viewModel: SiteListViewModel = hiltViewModel()
) { ) {
@ -159,7 +160,7 @@ fun SiteListScreen(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(onClick = { viewModel.cloneSite(site.id) }) { IconButton(onClick = { onCloneSite(uiState.instanceId, site.id) }) {
Icon( Icon(
Icons.Default.ContentCopy, Icons.Default.ContentCopy,
contentDescription = "Clone site" contentDescription = "Clone site"