feat: unified site picker with validation and instance management

Replace the two-step instance/site switching flow with a single Site
Picker screen that shows all sites grouped by instance. One tap to
switch to any site regardless of which instance it belongs to.

- Add SitePickerScreen with grouped LazyColumn (instance headers +
  site rows), inline add/edit/delete, current selection highlight
- Validate new site IDs against the Plausible API before adding
- Support deleting instances with cascading site removal
- Simplify Dashboard to single switch button
- Simplify Setup to add-only (no instance picker), with back arrow
- Remove old SiteListScreen/SiteListViewModel (replaced by SitePicker)
- Add selectAll query to StoredSite.sq, getAllSites/deleteSitesForInstance
  to SiteRepository

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-20 13:59:52 +01:00
commit 9110af7b8f
11 changed files with 742 additions and 441 deletions

View file

@ -14,6 +14,12 @@ import javax.inject.Singleton
class SiteRepository @Inject constructor(
private val database: ImplausiblyDatabase
) {
fun getAllSites(): List<Site> {
return database.storedSiteQueries.selectAll()
.executeAsList()
.map { Site(id = it.site_id, instanceId = it.instance_id) }
}
fun getSitesForInstance(instanceId: String): List<Site> {
return database.storedSiteQueries.selectByInstance(instanceId)
.executeAsList()
@ -35,6 +41,10 @@ class SiteRepository @Inject constructor(
)
}
fun deleteSitesForInstance(instanceId: String) {
database.storedSiteQueries.deleteByInstance(instanceId)
}
fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) {
database.storedSiteQueries.delete(site_id = oldSiteId, instance_id = instanceId)
database.storedSiteQueries.insert(site_id = newSiteId, instance_id = instanceId)

View file

@ -10,7 +10,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import android.content.Intent
import android.net.Uri
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.ExperimentalMaterial3Api
@ -45,9 +44,7 @@ import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
onNavigateToSetup: () -> Unit,
onNavigateToSites: (instanceId: String) -> Unit,
onBack: () -> Unit = {},
onNavigateToSitePicker: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@ -66,11 +63,8 @@ fun DashboardScreen(
Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
}
}
IconButton(onClick = { onNavigateToSites(uiState.instanceId) }) {
Icon(Icons.Default.Language, contentDescription = "Switch site")
}
IconButton(onClick = onNavigateToSetup) {
Icon(Icons.Default.SwapHoriz, contentDescription = "Switch instance")
IconButton(onClick = onNavigateToSitePicker) {
Icon(Icons.Default.SwapHoriz, contentDescription = "Switch site")
}
}
)

View file

@ -14,7 +14,7 @@ import no.naiv.implausibly.data.repository.InstanceRepository
import no.naiv.implausibly.ui.common.LoadingIndicator
import no.naiv.implausibly.ui.dashboard.DashboardScreen
import no.naiv.implausibly.ui.setup.SetupScreen
import no.naiv.implausibly.ui.sites.SiteListScreen
import no.naiv.implausibly.ui.sitepicker.SitePickerScreen
@Composable
fun AppNavHost(
@ -35,6 +35,7 @@ fun AppNavHost(
val siteId = appPreferences.selectedSiteId.first()
val destination = when {
// Saved selection still valid → go straight to Dashboard
instanceId != null && siteId != null -> {
val instance = instanceRepository.getById(instanceId)
if (instance != null) {
@ -43,7 +44,9 @@ fun AppNavHost(
Routes.setup()
}
}
instanceId != null -> Routes.siteList(instanceId)
// Instances exist but no site selected → pick one
instanceRepository.getAll().isNotEmpty() -> Routes.SITE_PICKER
// First run → add an instance
else -> Routes.setup()
}
@ -68,30 +71,36 @@ fun AppNavHost(
defaultValue = null
}
)
) {
) { backStackEntry ->
// Show back arrow if there's somewhere to go back to
val canGoBack = navController.previousBackStackEntry != null
SetupScreen(
onInstanceAdded = { instanceId ->
navController.navigate(Routes.siteList(instanceId)) {
onInstanceAdded = {
navController.navigate(Routes.SITE_PICKER) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}
},
onBack = if (canGoBack) {
{ navController.popBackStack() }
} else null
)
}
composable(
route = Routes.SITE_LIST,
arguments = listOf(navArgument("instanceId") { type = NavType.StringType })
) {
SiteListScreen(
composable(Routes.SITE_PICKER) {
// Show back arrow only if Dashboard is on the back stack
val canGoBack = navController.previousBackStackEntry != null
SitePickerScreen(
onSiteSelected = { instanceId, siteId ->
navController.navigate(Routes.dashboard(instanceId, siteId)) {
popUpTo(Routes.SETUP) { inclusive = true }
popUpTo(Routes.SITE_PICKER) { inclusive = true }
}
},
onCloneSite = { instanceId, siteId ->
navController.navigate(Routes.setupClone(instanceId, siteId))
onAddInstance = {
navController.navigate(Routes.setup())
},
onBack = { navController.popBackStack() }
onBack = if (canGoBack) {
{ navController.popBackStack() }
} else null
)
}
@ -103,15 +112,8 @@ fun AppNavHost(
)
) {
DashboardScreen(
onNavigateToSetup = {
navController.navigate(Routes.setup()) {
popUpTo(0) { inclusive = true }
}
},
onNavigateToSites = { instanceId ->
navController.navigate(Routes.siteList(instanceId)) {
popUpTo(Routes.DASHBOARD) { inclusive = true }
}
onNavigateToSitePicker = {
navController.navigate(Routes.SITE_PICKER)
}
)
}

View file

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

View file

@ -1,7 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.setup
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -11,12 +10,12 @@ 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.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -24,6 +23,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -37,9 +37,11 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetupScreen(
onInstanceAdded: (String) -> Unit,
onBack: (() -> Unit)? = null,
viewModel: SetupViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@ -49,7 +51,23 @@ fun SetupScreen(
uiState.savedInstanceId?.let { onInstanceAdded(it) }
}
Scaffold { paddingValues ->
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add instance") },
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
@ -57,13 +75,7 @@ fun SetupScreen(
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Implausibly",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Connect to your Plausible Analytics instance",
@ -73,46 +85,6 @@ fun SetupScreen(
Spacer(modifier = Modifier.height(24.dp))
// Existing instances
if (uiState.existingInstances.isNotEmpty()) {
Text(
text = "Your instances",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
uiState.existingInstances.forEach { instance ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { viewModel.selectExistingInstance(instance.id) }
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = instance.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = instance.baseUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Add new instance",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
}
// Instance URL
OutlinedTextField(
value = uiState.baseUrl,

View file

@ -23,14 +23,7 @@ data class SetupUiState(
val isTesting: Boolean = false,
val testResult: TestResult? = null,
val isSaving: Boolean = false,
val savedInstanceId: String? = null,
val existingInstances: List<ExistingInstance> = emptyList()
)
data class ExistingInstance(
val id: String,
val name: String,
val baseUrl: String
val savedInstanceId: String? = null
)
sealed class TestResult {
@ -53,7 +46,6 @@ class SetupViewModel @Inject constructor(
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
init {
loadExistingInstances()
if (cloneInstanceId != null) {
loadCloneData(cloneInstanceId, cloneSiteId)
}
@ -75,15 +67,6 @@ class SetupViewModel @Inject constructor(
}
}
private fun loadExistingInstances() {
viewModelScope.launch {
val instances = instanceRepository.getAll().map {
ExistingInstance(id = it.id, name = it.name, baseUrl = it.baseUrl)
}
_uiState.update { it.copy(existingInstances = instances) }
}
}
fun onBaseUrlChanged(url: String) {
_uiState.update { it.copy(baseUrl = url, testResult = null) }
}
@ -152,11 +135,4 @@ class SetupViewModel @Inject constructor(
_uiState.update { it.copy(isSaving = false, savedInstanceId = instance.id) }
}
}
fun selectExistingInstance(instanceId: String) {
viewModelScope.launch {
appPreferences.setSelectedInstanceId(instanceId)
_uiState.update { it.copy(savedInstanceId = instanceId) }
}
}
}

View file

@ -0,0 +1,395 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.sitepicker
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import no.naiv.implausibly.ui.common.LoadingIndicator
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SitePickerScreen(
onSiteSelected: (instanceId: String, siteId: String) -> Unit,
onAddInstance: () -> Unit,
onBack: (() -> Unit)? = null,
viewModel: SitePickerViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
// Site delete confirmation dialog
uiState.siteToDelete?.let { (instanceId, siteId) ->
AlertDialog(
onDismissRequest = viewModel::dismissDeleteSite,
title = { Text("Remove site") },
text = { Text("Remove $siteId from this instance?") },
confirmButton = {
TextButton(onClick = viewModel::confirmDeleteSite) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteSite) {
Text("Cancel")
}
}
)
}
// Instance delete confirmation dialog
uiState.instanceToDelete?.let { instance ->
val group = uiState.groups.find { it.instance.id == instance.id }
val siteCount = group?.sites?.size ?: 0
val siteWarning = if (siteCount > 0) {
"\n\nThis will also remove $siteCount site${if (siteCount != 1) "s" else ""}."
} else ""
AlertDialog(
onDismissRequest = viewModel::dismissDeleteInstance,
title = { Text("Delete instance") },
text = {
Text("Delete ${instance.name} (${instance.baseUrl})?$siteWarning")
},
confirmButton = {
TextButton(onClick = viewModel::confirmDeleteInstance) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteInstance) {
Text("Cancel")
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Choose a site") },
navigationIcon = {
if (onBack != null) {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
}
)
}
) { paddingValues ->
if (uiState.isLoading) {
LoadingIndicator()
return@Scaffold
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
uiState.groups.forEach { group ->
// Instance header
item(key = "header_${group.instance.id}") {
InstanceHeader(
group = group,
onToggle = { viewModel.toggleExpanded(group.instance.id) },
onDelete = { viewModel.requestDeleteInstance(group.instance) }
)
}
// Expanded content: sites + add-site input
if (group.isExpanded) {
items(
items = group.sites,
key = { "${group.instance.id}_${it.id}" }
) { site ->
val isSelected =
uiState.currentInstanceId == group.instance.id &&
uiState.currentSiteId == site.id
val isEditing = uiState.editingSiteId == site.id
SiteRow(
siteId = site.id,
isSelected = isSelected,
isEditing = isEditing,
editValue = uiState.editSiteValue,
onSelect = {
viewModel.selectSite(group.instance.id, site.id)
onSiteSelected(group.instance.id, site.id)
},
onStartEdit = { viewModel.startEditing(site.id) },
onEditValueChanged = viewModel::onEditSiteValueChanged,
onConfirmEdit = { viewModel.confirmEdit(group.instance.id) },
onCancelEdit = viewModel::cancelEdit,
onDelete = {
viewModel.requestDeleteSite(group.instance.id, site.id)
}
)
}
// Inline add-site input with validation
item(key = "add_${group.instance.id}") {
AddSiteRow(
value = group.newSiteId,
isTesting = group.isTestingSite,
error = group.siteTestError,
onValueChanged = {
viewModel.onNewSiteIdChanged(group.instance.id, it)
},
onAdd = { viewModel.addSite(group.instance.id) }
)
}
}
// Spacer between instance groups
item(key = "spacer_${group.instance.id}") {
Spacer(modifier = Modifier.height(8.dp))
}
}
// "Add new instance" button at bottom
item(key = "add_instance") {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onAddInstance,
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Add new instance")
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
@Composable
private fun InstanceHeader(
group: InstanceWithSites,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(onClick = onToggle),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, bottom = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = group.instance.name,
style = MaterialTheme.typography.titleLarge
)
Text(
text = group.instance.baseUrl,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete instance",
tint = MaterialTheme.colorScheme.error
)
}
Icon(
imageVector = if (group.isExpanded) Icons.Default.ExpandLess
else Icons.Default.ExpandMore,
contentDescription = if (group.isExpanded) "Collapse" else "Expand"
)
}
}
}
@Composable
private fun SiteRow(
siteId: String,
isSelected: Boolean,
isEditing: Boolean,
editValue: String,
onSelect: () -> Unit,
onStartEdit: () -> Unit,
onEditValueChanged: (String) -> Unit,
onConfirmEdit: () -> Unit,
onCancelEdit: () -> Unit,
onDelete: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 8.dp)
.then(if (!isEditing) Modifier.clickable(onClick = onSelect) else Modifier),
colors = if (isSelected) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
} else {
CardDefaults.cardColors()
}
) {
if (isEditing) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = editValue,
onValueChange = onEditValueChanged,
singleLine = true,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onConfirmEdit) {
Icon(
Icons.Default.Check,
contentDescription = "Save",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = onCancelEdit) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Language,
contentDescription = null,
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = siteId,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurface
)
IconButton(onClick = onStartEdit) {
Icon(Icons.Default.Edit, contentDescription = "Edit site ID")
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove site",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
@Composable
private fun AddSiteRow(
value: String,
isTesting: Boolean,
error: String?,
onValueChanged: (String) -> Unit,
onAdd: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = value,
onValueChange = onValueChanged,
label = { Text("Add site ID") },
placeholder = { Text("example.com") },
singleLine = true,
isError = error != null,
enabled = !isTesting,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
if (isTesting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
IconButton(
onClick = onAdd,
enabled = value.isNotBlank()
) {
Icon(Icons.Default.Add, contentDescription = "Add site")
}
}
}
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 4.dp, top = 4.dp)
)
}
}
}

View file

@ -0,0 +1,280 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.sitepicker
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import no.naiv.implausibly.data.AppPreferences
import no.naiv.implausibly.data.repository.InstanceRepository
import no.naiv.implausibly.data.repository.SiteRepository
import no.naiv.implausibly.domain.model.PlausibleInstance
import no.naiv.implausibly.domain.model.Site
import javax.inject.Inject
data class InstanceWithSites(
val instance: PlausibleInstance,
val sites: List<Site>,
val isExpanded: Boolean = true,
val newSiteId: String = "",
val isTestingSite: Boolean = false,
val siteTestError: String? = null
)
data class SitePickerUiState(
val groups: List<InstanceWithSites> = emptyList(),
val currentInstanceId: String? = null,
val currentSiteId: String? = null,
val isLoading: Boolean = true,
val editingSiteId: String? = null,
val editSiteValue: String = "",
val siteToDelete: Pair<String, String>? = null,
val instanceToDelete: PlausibleInstance? = null
)
@HiltViewModel
class SitePickerViewModel @Inject constructor(
private val instanceRepository: InstanceRepository,
private val siteRepository: SiteRepository,
private val appPreferences: AppPreferences
) : ViewModel() {
private val _uiState = MutableStateFlow(SitePickerUiState())
val uiState: StateFlow<SitePickerUiState> = _uiState.asStateFlow()
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
val instances = instanceRepository.getAll()
val allSites = siteRepository.getAllSites()
val sitesByInstance = allSites.groupBy { it.instanceId }
val currentInstanceId = appPreferences.selectedInstanceId.first()
val currentSiteId = appPreferences.selectedSiteId.first()
val groups = instances.map { instance ->
InstanceWithSites(
instance = instance,
sites = sitesByInstance[instance.id] ?: emptyList()
)
}
_uiState.update {
it.copy(
groups = groups,
currentInstanceId = currentInstanceId,
currentSiteId = currentSiteId,
isLoading = false
)
}
}
}
fun toggleExpanded(instanceId: String) {
_uiState.update { state ->
state.copy(
groups = state.groups.map { group ->
if (group.instance.id == instanceId) {
group.copy(isExpanded = !group.isExpanded)
} else group
}
)
}
}
fun onNewSiteIdChanged(instanceId: String, value: String) {
_uiState.update { state ->
state.copy(
groups = state.groups.map { group ->
if (group.instance.id == instanceId) {
group.copy(newSiteId = value, siteTestError = null)
} else group
}
)
}
}
/**
* Validates the site ID against the Plausible API before adding it.
* Uses the same testConnection call as Setup fires a minimal query
* to confirm the site exists and is accessible.
*/
fun addSite(instanceId: String) {
val group = _uiState.value.groups.find { it.instance.id == instanceId } ?: return
val siteId = group.newSiteId.trim()
if (siteId.isBlank() || group.isTestingSite) return
// Set testing state
_uiState.update { state ->
state.copy(
groups = state.groups.map { g ->
if (g.instance.id == instanceId) {
g.copy(isTestingSite = true, siteTestError = null)
} else g
}
)
}
viewModelScope.launch {
val instance = group.instance
val apiKey = instanceRepository.getApiKey(instance)
val error = instanceRepository.testConnection(
baseUrl = instance.baseUrl,
apiKey = apiKey,
siteId = siteId
)
if (error != null) {
// Validation failed — show error, don't add
_uiState.update { state ->
state.copy(
groups = state.groups.map { g ->
if (g.instance.id == instanceId) {
g.copy(isTestingSite = false, siteTestError = error)
} else g
}
)
}
return@launch
}
// Validation passed — add the site
siteRepository.addSite(siteId, instanceId)
val updatedSites = siteRepository.getSitesForInstance(instanceId)
_uiState.update { state ->
state.copy(
groups = state.groups.map { g ->
if (g.instance.id == instanceId) {
g.copy(
sites = updatedSites,
newSiteId = "",
isTestingSite = false,
siteTestError = null
)
} else g
}
)
}
}
}
// -- Site deletion --
fun requestDeleteSite(instanceId: String, siteId: String) {
_uiState.update { it.copy(siteToDelete = instanceId to siteId) }
}
fun dismissDeleteSite() {
_uiState.update { it.copy(siteToDelete = null) }
}
fun confirmDeleteSite() {
val (instanceId, siteId) = _uiState.value.siteToDelete ?: return
viewModelScope.launch {
siteRepository.removeSite(siteId, instanceId)
val updatedSites = siteRepository.getSitesForInstance(instanceId)
_uiState.update { state ->
state.copy(
groups = state.groups.map { g ->
if (g.instance.id == instanceId) {
g.copy(sites = updatedSites)
} else g
},
siteToDelete = null
)
}
}
}
// -- Instance deletion --
fun requestDeleteInstance(instance: PlausibleInstance) {
_uiState.update { it.copy(instanceToDelete = instance) }
}
fun dismissDeleteInstance() {
_uiState.update { it.copy(instanceToDelete = null) }
}
fun confirmDeleteInstance() {
val instance = _uiState.value.instanceToDelete ?: return
viewModelScope.launch {
siteRepository.deleteSitesForInstance(instance.id)
instanceRepository.deleteInstance(instance.id)
// Clear selection if the deleted instance was selected
val state = _uiState.value
if (state.currentInstanceId == instance.id) {
appPreferences.setSelectedInstanceId(null)
appPreferences.setSelectedSiteId(null)
}
_uiState.update { s ->
s.copy(
groups = s.groups.filter { it.instance.id != instance.id },
instanceToDelete = null,
currentInstanceId = if (s.currentInstanceId == instance.id) null else s.currentInstanceId,
currentSiteId = if (s.currentInstanceId == instance.id) null else s.currentSiteId
)
}
}
}
// -- Site editing --
fun startEditing(siteId: String) {
_uiState.update { it.copy(editingSiteId = siteId, editSiteValue = siteId) }
}
fun onEditSiteValueChanged(value: String) {
_uiState.update { it.copy(editSiteValue = value) }
}
fun confirmEdit(instanceId: String) {
val state = _uiState.value
val oldId = state.editingSiteId ?: return
val newId = state.editSiteValue.trim()
if (newId.isBlank() || newId == oldId) {
cancelEdit()
return
}
viewModelScope.launch {
siteRepository.updateSite(oldId, newId, instanceId)
val updatedSites = siteRepository.getSitesForInstance(instanceId)
_uiState.update { s ->
s.copy(
groups = s.groups.map { g ->
if (g.instance.id == instanceId) {
g.copy(sites = updatedSites)
} else g
},
editingSiteId = null,
editSiteValue = ""
)
}
}
}
fun cancelEdit() {
_uiState.update { it.copy(editingSiteId = null, editSiteValue = "") }
}
/**
* Persist the user's selection so the app opens directly to Dashboard next time.
*/
fun selectSite(instanceId: String, siteId: String) {
viewModelScope.launch {
appPreferences.setSelectedInstanceId(instanceId)
appPreferences.setSelectedSiteId(siteId)
}
}
}

View file

@ -1,210 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.sites
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SiteListScreen(
onSiteSelected: (instanceId: String, siteId: String) -> Unit,
onCloneSite: (instanceId: String, siteId: String) -> Unit = { _, _ -> },
onBack: () -> Unit,
viewModel: SiteListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
var siteToDelete by remember { mutableStateOf<String?>(null) }
// Delete confirmation dialog
siteToDelete?.let { siteId ->
AlertDialog(
onDismissRequest = { siteToDelete = null },
title = { Text("Remove site") },
text = { Text("Remove $siteId from this instance?") },
confirmButton = {
TextButton(onClick = {
viewModel.removeSite(siteId)
siteToDelete = null
}) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { siteToDelete = null }) {
Text("Cancel")
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(uiState.instanceName.ifEmpty { "Sites" })
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
// Add site input
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = uiState.newSiteId,
onValueChange = viewModel::onNewSiteIdChanged,
label = { Text("Add site ID") },
placeholder = { Text("example.com") },
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = viewModel::addSite,
enabled = uiState.newSiteId.isNotBlank()
) {
Icon(Icons.Default.Add, contentDescription = "Add site")
}
}
Spacer(modifier = Modifier.height(16.dp))
if (uiState.sites.isEmpty()) {
Text(
text = "No sites added yet. Enter a site ID above to get started.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 24.dp)
)
}
LazyColumn {
items(uiState.sites, key = { it.id }) { site ->
val isEditing = uiState.editingSiteId == site.id
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.then(
if (!isEditing) Modifier.clickable {
onSiteSelected(uiState.instanceId, site.id)
} else Modifier
)
) {
if (isEditing) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = uiState.editSiteValue,
onValueChange = viewModel::onEditSiteValueChanged,
singleLine = true,
modifier = Modifier.weight(1f)
)
IconButton(onClick = viewModel::confirmEdit) {
Icon(
Icons.Default.Check,
contentDescription = "Save",
tint = MaterialTheme.colorScheme.primary
)
}
IconButton(onClick = viewModel::cancelEdit) {
Icon(Icons.Default.Close, contentDescription = "Cancel")
}
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Language,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = site.id,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onCloneSite(uiState.instanceId, site.id) }) {
Icon(Icons.Default.ContentCopy, contentDescription = "Clone as new instance")
}
IconButton(onClick = { viewModel.startEditing(site.id) }) {
Icon(Icons.Default.Edit, contentDescription = "Edit site ID")
}
IconButton(onClick = { siteToDelete = site.id }) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove site",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
}
}
}

View file

@ -1,120 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.sites
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import no.naiv.implausibly.data.repository.InstanceRepository
import no.naiv.implausibly.data.repository.SiteRepository
import no.naiv.implausibly.domain.model.Site
import javax.inject.Inject
data class SiteListUiState(
val instanceName: String = "",
val instanceId: String = "",
val sites: List<Site> = emptyList(),
val newSiteId: String = "",
val editingSiteId: String? = null,
val editSiteValue: String = ""
)
@HiltViewModel
class SiteListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val instanceRepository: InstanceRepository,
private val siteRepository: SiteRepository
) : ViewModel() {
private val instanceId: String = savedStateHandle["instanceId"] ?: ""
private val _uiState = MutableStateFlow(SiteListUiState(instanceId = instanceId))
val uiState: StateFlow<SiteListUiState> = _uiState.asStateFlow()
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
val instance = instanceRepository.getById(instanceId)
val sites = siteRepository.getSitesForInstance(instanceId)
_uiState.update {
it.copy(
instanceName = instance?.name ?: "",
sites = sites
)
}
}
}
fun onNewSiteIdChanged(siteId: String) {
_uiState.update { it.copy(newSiteId = siteId) }
}
fun addSite() {
val siteId = _uiState.value.newSiteId.trim()
if (siteId.isBlank()) return
viewModelScope.launch {
siteRepository.addSite(siteId, instanceId)
_uiState.update {
it.copy(
sites = siteRepository.getSitesForInstance(instanceId),
newSiteId = ""
)
}
}
}
fun removeSite(siteId: String) {
viewModelScope.launch {
siteRepository.removeSite(siteId, instanceId)
_uiState.update {
it.copy(sites = siteRepository.getSitesForInstance(instanceId))
}
}
}
fun startEditing(siteId: String) {
_uiState.update { it.copy(editingSiteId = siteId, editSiteValue = siteId) }
}
fun onEditSiteValueChanged(value: String) {
_uiState.update { it.copy(editSiteValue = value) }
}
fun confirmEdit() {
val state = _uiState.value
val oldId = state.editingSiteId ?: return
val newId = state.editSiteValue.trim()
if (newId.isBlank() || newId == oldId) {
cancelEdit()
return
}
viewModelScope.launch {
siteRepository.updateSite(oldId, newId, instanceId)
_uiState.update {
it.copy(
sites = siteRepository.getSitesForInstance(instanceId),
editingSiteId = null,
editSiteValue = ""
)
}
}
}
fun cancelEdit() {
_uiState.update { it.copy(editingSiteId = null, editSiteValue = "") }
}
fun cloneSite(siteId: String) {
_uiState.update { it.copy(newSiteId = siteId) }
}
}

View file

@ -17,3 +17,6 @@ DELETE FROM stored_sites WHERE site_id = ? AND instance_id = ?;
deleteByInstance:
DELETE FROM stored_sites WHERE instance_id = ?;
selectAll:
SELECT * FROM stored_sites ORDER BY instance_id ASC, site_id ASC;