diff --git a/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt b/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt index 9c8af90..cdd12a1 100644 --- a/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt +++ b/app/src/main/java/no/naiv/implausibly/data/repository/SiteRepository.kt @@ -14,6 +14,12 @@ import javax.inject.Singleton class SiteRepository @Inject constructor( private val database: ImplausiblyDatabase ) { + fun getAllSites(): List { + return database.storedSiteQueries.selectAll() + .executeAsList() + .map { Site(id = it.site_id, instanceId = it.instance_id) } + } + fun getSitesForInstance(instanceId: String): List { 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) diff --git a/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt b/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt index 71c5c5d..65c9a24 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt @@ -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") } } ) diff --git a/app/src/main/java/no/naiv/implausibly/ui/navigation/AppNavHost.kt b/app/src/main/java/no/naiv/implausibly/ui/navigation/AppNavHost.kt index a68bc0b..966ca19 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/navigation/AppNavHost.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/navigation/AppNavHost.kt @@ -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) } ) } diff --git a/app/src/main/java/no/naiv/implausibly/ui/navigation/Routes.kt b/app/src/main/java/no/naiv/implausibly/ui/navigation/Routes.kt index 87611fb..67f41d7 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/navigation/Routes.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/navigation/Routes.kt @@ -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" } diff --git a/app/src/main/java/no/naiv/implausibly/ui/setup/SetupScreen.kt b/app/src/main/java/no/naiv/implausibly/ui/setup/SetupScreen.kt index ec4f2c7..24fd5d9 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/setup/SetupScreen.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/setup/SetupScreen.kt @@ -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, diff --git a/app/src/main/java/no/naiv/implausibly/ui/setup/SetupViewModel.kt b/app/src/main/java/no/naiv/implausibly/ui/setup/SetupViewModel.kt index 973e6e0..b8ec780 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/setup/SetupViewModel.kt @@ -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 = 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 = _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) } - } - } } diff --git a/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerScreen.kt b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerScreen.kt new file mode 100644 index 0000000..7328d0b --- /dev/null +++ b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerScreen.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerViewModel.kt b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerViewModel.kt new file mode 100644 index 0000000..b7ad3d8 --- /dev/null +++ b/app/src/main/java/no/naiv/implausibly/ui/sitepicker/SitePickerViewModel.kt @@ -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, + val isExpanded: Boolean = true, + val newSiteId: String = "", + val isTestingSite: Boolean = false, + val siteTestError: String? = null +) + +data class SitePickerUiState( + val groups: List = emptyList(), + val currentInstanceId: String? = null, + val currentSiteId: String? = null, + val isLoading: Boolean = true, + val editingSiteId: String? = null, + val editSiteValue: String = "", + val siteToDelete: Pair? = 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 = _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) + } + } +} diff --git a/app/src/main/java/no/naiv/implausibly/ui/sites/SiteListScreen.kt b/app/src/main/java/no/naiv/implausibly/ui/sites/SiteListScreen.kt deleted file mode 100644 index 72979f0..0000000 --- a/app/src/main/java/no/naiv/implausibly/ui/sites/SiteListScreen.kt +++ /dev/null @@ -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(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 - ) - } - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/no/naiv/implausibly/ui/sites/SiteListViewModel.kt b/app/src/main/java/no/naiv/implausibly/ui/sites/SiteListViewModel.kt deleted file mode 100644 index d6404d9..0000000 --- a/app/src/main/java/no/naiv/implausibly/ui/sites/SiteListViewModel.kt +++ /dev/null @@ -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 = 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 = _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) } - } -} diff --git a/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq b/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq index be947a0..8ed7c9d 100644 --- a/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq +++ b/app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq @@ -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;