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:
parent
ebe1d18123
commit
9110af7b8f
11 changed files with 742 additions and 441 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue