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(
|
class SiteRepository @Inject constructor(
|
||||||
private val database: ImplausiblyDatabase
|
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> {
|
fun getSitesForInstance(instanceId: String): List<Site> {
|
||||||
return database.storedSiteQueries.selectByInstance(instanceId)
|
return database.storedSiteQueries.selectByInstance(instanceId)
|
||||||
.executeAsList()
|
.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) {
|
fun updateSite(oldSiteId: String, newSiteId: String, instanceId: String) {
|
||||||
database.storedSiteQueries.delete(site_id = oldSiteId, instance_id = instanceId)
|
database.storedSiteQueries.delete(site_id = oldSiteId, instance_id = instanceId)
|
||||||
database.storedSiteQueries.insert(site_id = newSiteId, 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.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.material.icons.Icons
|
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.OpenInBrowser
|
||||||
import androidx.compose.material.icons.filled.SwapHoriz
|
import androidx.compose.material.icons.filled.SwapHoriz
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -45,9 +44,7 @@ import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
onNavigateToSetup: () -> Unit,
|
onNavigateToSitePicker: () -> Unit,
|
||||||
onNavigateToSites: (instanceId: String) -> Unit,
|
|
||||||
onBack: () -> Unit = {},
|
|
||||||
viewModel: DashboardViewModel = hiltViewModel()
|
viewModel: DashboardViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
@ -66,11 +63,8 @@ fun DashboardScreen(
|
||||||
Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
|
Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { onNavigateToSites(uiState.instanceId) }) {
|
IconButton(onClick = onNavigateToSitePicker) {
|
||||||
Icon(Icons.Default.Language, contentDescription = "Switch site")
|
Icon(Icons.Default.SwapHoriz, contentDescription = "Switch site")
|
||||||
}
|
|
||||||
IconButton(onClick = onNavigateToSetup) {
|
|
||||||
Icon(Icons.Default.SwapHoriz, contentDescription = "Switch instance")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import no.naiv.implausibly.data.repository.InstanceRepository
|
||||||
import no.naiv.implausibly.ui.common.LoadingIndicator
|
import no.naiv.implausibly.ui.common.LoadingIndicator
|
||||||
import no.naiv.implausibly.ui.dashboard.DashboardScreen
|
import no.naiv.implausibly.ui.dashboard.DashboardScreen
|
||||||
import no.naiv.implausibly.ui.setup.SetupScreen
|
import no.naiv.implausibly.ui.setup.SetupScreen
|
||||||
import no.naiv.implausibly.ui.sites.SiteListScreen
|
import no.naiv.implausibly.ui.sitepicker.SitePickerScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(
|
fun AppNavHost(
|
||||||
|
|
@ -35,6 +35,7 @@ fun AppNavHost(
|
||||||
val siteId = appPreferences.selectedSiteId.first()
|
val siteId = appPreferences.selectedSiteId.first()
|
||||||
|
|
||||||
val destination = when {
|
val destination = when {
|
||||||
|
// Saved selection still valid → go straight to Dashboard
|
||||||
instanceId != null && siteId != null -> {
|
instanceId != null && siteId != null -> {
|
||||||
val instance = instanceRepository.getById(instanceId)
|
val instance = instanceRepository.getById(instanceId)
|
||||||
if (instance != null) {
|
if (instance != null) {
|
||||||
|
|
@ -43,7 +44,9 @@ fun AppNavHost(
|
||||||
Routes.setup()
|
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()
|
else -> Routes.setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,30 +71,36 @@ fun AppNavHost(
|
||||||
defaultValue = null
|
defaultValue = null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
) {
|
) { backStackEntry ->
|
||||||
|
// Show back arrow if there's somewhere to go back to
|
||||||
|
val canGoBack = navController.previousBackStackEntry != null
|
||||||
SetupScreen(
|
SetupScreen(
|
||||||
onInstanceAdded = { instanceId ->
|
onInstanceAdded = {
|
||||||
navController.navigate(Routes.siteList(instanceId)) {
|
navController.navigate(Routes.SITE_PICKER) {
|
||||||
popUpTo(Routes.SETUP) { inclusive = true }
|
popUpTo(Routes.SETUP) { inclusive = true }
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onBack = if (canGoBack) {
|
||||||
|
{ navController.popBackStack() }
|
||||||
|
} else null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(Routes.SITE_PICKER) {
|
||||||
route = Routes.SITE_LIST,
|
// Show back arrow only if Dashboard is on the back stack
|
||||||
arguments = listOf(navArgument("instanceId") { type = NavType.StringType })
|
val canGoBack = navController.previousBackStackEntry != null
|
||||||
) {
|
SitePickerScreen(
|
||||||
SiteListScreen(
|
|
||||||
onSiteSelected = { instanceId, siteId ->
|
onSiteSelected = { instanceId, siteId ->
|
||||||
navController.navigate(Routes.dashboard(instanceId, siteId)) {
|
navController.navigate(Routes.dashboard(instanceId, siteId)) {
|
||||||
popUpTo(Routes.SETUP) { inclusive = true }
|
popUpTo(Routes.SITE_PICKER) { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCloneSite = { instanceId, siteId ->
|
onAddInstance = {
|
||||||
navController.navigate(Routes.setupClone(instanceId, siteId))
|
navController.navigate(Routes.setup())
|
||||||
},
|
},
|
||||||
onBack = { navController.popBackStack() }
|
onBack = if (canGoBack) {
|
||||||
|
{ navController.popBackStack() }
|
||||||
|
} else null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,15 +112,8 @@ fun AppNavHost(
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
DashboardScreen(
|
DashboardScreen(
|
||||||
onNavigateToSetup = {
|
onNavigateToSitePicker = {
|
||||||
navController.navigate(Routes.setup()) {
|
navController.navigate(Routes.SITE_PICKER)
|
||||||
popUpTo(0) { inclusive = true }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onNavigateToSites = { instanceId ->
|
|
||||||
navController.navigate(Routes.siteList(instanceId)) {
|
|
||||||
popUpTo(Routes.DASHBOARD) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ package no.naiv.implausibly.ui.navigation
|
||||||
object Routes {
|
object Routes {
|
||||||
const val LOADING = "loading"
|
const val LOADING = "loading"
|
||||||
const val SETUP = "setup?cloneInstanceId={cloneInstanceId}&cloneSiteId={cloneSiteId}"
|
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}"
|
const val DASHBOARD = "dashboard/{instanceId}/{siteId}"
|
||||||
|
|
||||||
fun setup() = "setup"
|
fun setup() = "setup"
|
||||||
fun setupClone(instanceId: String, siteId: String) =
|
fun setupClone(instanceId: String, siteId: String) =
|
||||||
"setup?cloneInstanceId=$instanceId&cloneSiteId=$siteId"
|
"setup?cloneInstanceId=$instanceId&cloneSiteId=$siteId"
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// 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.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -24,6 +23,7 @@ import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -37,9 +37,11 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupScreen(
|
fun SetupScreen(
|
||||||
onInstanceAdded: (String) -> Unit,
|
onInstanceAdded: (String) -> Unit,
|
||||||
|
onBack: (() -> Unit)? = null,
|
||||||
viewModel: SetupViewModel = hiltViewModel()
|
viewModel: SetupViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
@ -49,7 +51,23 @@ fun SetupScreen(
|
||||||
uiState.savedInstanceId?.let { onInstanceAdded(it) }
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -57,13 +75,7 @@ fun SetupScreen(
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Implausibly",
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Connect to your Plausible Analytics instance",
|
text = "Connect to your Plausible Analytics instance",
|
||||||
|
|
@ -73,46 +85,6 @@ fun SetupScreen(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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
|
// Instance URL
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.baseUrl,
|
value = uiState.baseUrl,
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,7 @@ data class SetupUiState(
|
||||||
val isTesting: Boolean = false,
|
val isTesting: Boolean = false,
|
||||||
val testResult: TestResult? = null,
|
val testResult: TestResult? = null,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val savedInstanceId: String? = null,
|
val savedInstanceId: String? = null
|
||||||
val existingInstances: List<ExistingInstance> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ExistingInstance(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val baseUrl: String
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class TestResult {
|
sealed class TestResult {
|
||||||
|
|
@ -53,7 +46,6 @@ class SetupViewModel @Inject constructor(
|
||||||
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadExistingInstances()
|
|
||||||
if (cloneInstanceId != null) {
|
if (cloneInstanceId != null) {
|
||||||
loadCloneData(cloneInstanceId, cloneSiteId)
|
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) {
|
fun onBaseUrlChanged(url: String) {
|
||||||
_uiState.update { it.copy(baseUrl = url, testResult = null) }
|
_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) }
|
_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:
|
deleteByInstance:
|
||||||
DELETE FROM stored_sites WHERE instance_id = ?;
|
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