fix: API compatibility, smart navigation, and dashboard controls

- Fix Plausible API v2 compatibility: move `limit` into `pagination`
  object, add `expectSuccess = true` to Ktor client so API errors are
  surfaced instead of "Illegal input" serialization errors, configure
  `explicitNulls = false` to avoid sending null fields
- Add smart start destination: app checks DataStore on launch and
  navigates directly to dashboard if an instance/site was previously
  selected, skipping the setup screen
- Add settings and sites icons to dashboard top bar for navigating
  back to instance management and site list
- Add site editing (inline rename) to site list screen
- Fix DateRangeSelector crash caused by sealed class data object
  initialization issue in Compose lambda captures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 16:50:06 +01:00
commit d9e4b18a52
5 changed files with 114 additions and 13 deletions

View file

@ -6,17 +6,30 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import no.naiv.implausibly.data.AppPreferences
import no.naiv.implausibly.data.repository.InstanceRepository
import no.naiv.implausibly.data.repository.SiteRepository
import no.naiv.implausibly.ui.navigation.AppNavHost
import no.naiv.implausibly.ui.theme.ImplausiblyTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var appPreferences: AppPreferences
@Inject lateinit var instanceRepository: InstanceRepository
@Inject lateinit var siteRepository: SiteRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ImplausiblyTheme {
AppNavHost()
AppNavHost(
appPreferences = appPreferences,
instanceRepository = instanceRepository,
siteRepository = siteRepository
)
}
}
}

View file

@ -9,6 +9,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -23,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import no.naiv.implausibly.domain.model.DashboardData
import no.naiv.implausibly.domain.model.DateRange
import no.naiv.implausibly.ui.common.ErrorState
import no.naiv.implausibly.ui.common.LoadingIndicator
import no.naiv.implausibly.ui.common.UiState
@ -36,6 +39,8 @@ import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection
@Composable
fun DashboardScreen(
onBack: () -> Unit,
onNavigateToSetup: () -> Unit,
onNavigateToSites: (instanceId: String) -> Unit,
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
@ -48,6 +53,14 @@ fun DashboardScreen(
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { onNavigateToSites(uiState.instanceId) }) {
Icon(Icons.Default.Language, contentDescription = "Sites")
}
IconButton(onClick = onNavigateToSetup) {
Icon(Icons.Default.Settings, contentDescription = "Instances")
}
}
)
}
@ -78,7 +91,7 @@ fun DashboardScreen(
private fun DashboardContent(
data: DashboardData,
uiState: DashboardUiState,
onDateRangeSelected: (no.naiv.implausibly.domain.model.DateRange) -> Unit,
onDateRangeSelected: (DateRange) -> Unit,
contentPadding: PaddingValues
) {
LazyColumn(
@ -91,7 +104,6 @@ private fun DashboardContent(
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Date range selector
item {
DateRangeSelector(
selected = uiState.dateRange,
@ -99,12 +111,10 @@ private fun DashboardContent(
)
}
// Top stats row
item {
StatCard(stats = data.topStats)
}
// Visitor chart
item {
VisitorChart(
points = data.timeSeries,
@ -112,12 +122,10 @@ private fun DashboardContent(
)
}
// Top sources
item {
TopSourcesSection(entries = data.topSources)
}
// Top pages
item {
TopPagesSection(entries = data.topPages)
}

View file

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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.StatsRepository
import no.naiv.implausibly.domain.model.DashboardData
@ -18,6 +19,7 @@ import no.naiv.implausibly.ui.common.UiState
import javax.inject.Inject
data class DashboardUiState(
val instanceId: String = "",
val siteId: String = "",
val dateRange: DateRange = DateRange.ThirtyDays,
val dataState: UiState<DashboardData> = UiState.Loading,
@ -28,16 +30,22 @@ data class DashboardUiState(
class DashboardViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val instanceRepository: InstanceRepository,
private val statsRepository: StatsRepository
private val statsRepository: StatsRepository,
private val appPreferences: AppPreferences
) : ViewModel() {
private val instanceId: String = savedStateHandle["instanceId"] ?: ""
private val siteId: String = savedStateHandle["siteId"] ?: ""
private val _uiState = MutableStateFlow(DashboardUiState(siteId = siteId))
private val _uiState = MutableStateFlow(DashboardUiState(instanceId = instanceId, siteId = siteId))
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
init {
// Remember this selection so the app opens here next time
viewModelScope.launch {
appPreferences.setSelectedInstanceId(instanceId)
appPreferences.setSelectedSiteId(siteId)
}
loadDashboard()
}

View file

@ -2,23 +2,82 @@
package no.naiv.implausibly.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import no.naiv.implausibly.data.AppPreferences
import no.naiv.implausibly.data.repository.InstanceRepository
import no.naiv.implausibly.data.repository.SiteRepository
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
/**
* App-level navigation host.
*
* On launch, checks DataStore for a previously selected instance/site.
* If found, navigates directly to the dashboard (or site list if no site
* is selected). Otherwise, shows the setup screen.
*/
@Composable
fun AppNavHost() {
fun AppNavHost(
appPreferences: AppPreferences,
instanceRepository: InstanceRepository,
siteRepository: SiteRepository
) {
val navController = rememberNavController()
// Collect saved preferences to determine start destination
val savedInstanceId by appPreferences.selectedInstanceId.collectAsState(initial = null)
val savedSiteId by appPreferences.selectedSiteId.collectAsState(initial = null)
var hasNavigated by remember { mutableStateOf(false) }
NavHost(
navController = navController,
startDestination = Routes.SETUP
startDestination = Routes.LOADING
) {
// Loading screen — resolves start destination from preferences
composable(Routes.LOADING) {
LoadingIndicator()
LaunchedEffect(savedInstanceId, savedSiteId) {
// Wait for preferences to load (initial null vs actual null)
// Once we have a value (even if null), navigate
if (hasNavigated) return@LaunchedEffect
hasNavigated = true
val instanceId = savedInstanceId
val siteId = savedSiteId
val destination = when {
instanceId != null && siteId != null -> {
// Verify the instance still exists
val instance = instanceRepository.getById(instanceId)
if (instance != null) {
Routes.dashboard(instanceId, siteId)
} else {
Routes.SETUP
}
}
instanceId != null -> Routes.siteList(instanceId)
else -> Routes.SETUP
}
navController.navigate(destination) {
popUpTo(Routes.LOADING) { inclusive = true }
}
}
}
composable(Routes.SETUP) {
SetupScreen(
onInstanceAdded = { instanceId ->
@ -35,7 +94,9 @@ fun AppNavHost() {
) {
SiteListScreen(
onSiteSelected = { instanceId, siteId ->
navController.navigate(Routes.dashboard(instanceId, siteId))
navController.navigate(Routes.dashboard(instanceId, siteId)) {
popUpTo(Routes.SETUP) { inclusive = true }
}
},
onBack = { navController.popBackStack() }
)
@ -49,7 +110,17 @@ fun AppNavHost() {
)
) {
DashboardScreen(
onBack = { navController.popBackStack() }
onBack = { navController.popBackStack() },
onNavigateToSetup = {
navController.navigate(Routes.SETUP) {
popUpTo(0) { inclusive = true }
}
},
onNavigateToSites = { instanceId ->
navController.navigate(Routes.siteList(instanceId)) {
popUpTo(Routes.DASHBOARD) { inclusive = true }
}
}
)
}
}

View file

@ -6,6 +6,7 @@ package no.naiv.implausibly.ui.navigation
* Arguments are passed as path parameters (strings only no complex objects).
*/
object Routes {
const val LOADING = "loading"
const val SETUP = "setup"
const val SITE_LIST = "site_list/{instanceId}"
const val DASHBOARD = "dashboard/{instanceId}/{siteId}"