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(
route = Routes.SITE_LIST,
arguments = listOf(navArgument("instanceId") { type = NavType.StringType })
@ -80,6 +96,9 @@ fun AppNavHost(
popUpTo(Routes.SETUP) { inclusive = true }
}
},
onCloneSite = { instanceId, siteId ->
navController.navigate(Routes.setupClone(instanceId, siteId))
},
onBack = { navController.popBackStack() }
)
}

View file

@ -8,9 +8,11 @@ package no.naiv.implausibly.ui.navigation
object Routes {
const val LOADING = "loading"
const val SETUP = "setup"
const val SETUP_CLONE = "setup?cloneInstanceId={cloneInstanceId}&cloneSiteId={cloneSiteId}"
const val SITE_LIST = "site_list/{instanceId}"
const val DASHBOARD = "dashboard/{instanceId}/{siteId}"
fun siteList(instanceId: String) = "site_list/$instanceId"
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
package no.naiv.implausibly.ui.setup
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -39,16 +40,39 @@ sealed class TestResult {
@HiltViewModel
class SetupViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val instanceRepository: InstanceRepository,
private val siteRepository: SiteRepository,
private val appPreferences: AppPreferences
) : ViewModel() {
private val cloneInstanceId: String? = savedStateHandle["cloneInstanceId"]
private val cloneSiteId: String? = savedStateHandle["cloneSiteId"]
private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
init {
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() {
@ -95,7 +119,6 @@ class SetupViewModel @Inject constructor(
isTesting = false,
testResult = if (error == null) TestResult.Success else TestResult.Failure(error),
name = if (error == null && it.name.isBlank()) {
// Auto-fill name from site ID if blank
state.siteId
} else {
it.name
@ -121,10 +144,8 @@ class SetupViewModel @Inject constructor(
apiKey = apiKey
)
// Also store the site ID
siteRepository.addSite(state.siteId, instance.id)
// Set as selected
appPreferences.setSelectedInstanceId(instance.id)
appPreferences.setSelectedSiteId(state.siteId)

View file

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