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.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint 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.navigation.AppNavHost
import no.naiv.implausibly.ui.theme.ImplausiblyTheme import no.naiv.implausibly.ui.theme.ImplausiblyTheme
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var appPreferences: AppPreferences
@Inject lateinit var instanceRepository: InstanceRepository
@Inject lateinit var siteRepository: SiteRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
ImplausiblyTheme { 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.foundation.lazy.LazyColumn
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.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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -23,6 +25,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import no.naiv.implausibly.domain.model.DashboardData 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.ErrorState
import no.naiv.implausibly.ui.common.LoadingIndicator import no.naiv.implausibly.ui.common.LoadingIndicator
import no.naiv.implausibly.ui.common.UiState import no.naiv.implausibly.ui.common.UiState
@ -36,6 +39,8 @@ import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToSetup: () -> Unit,
onNavigateToSites: (instanceId: String) -> Unit,
viewModel: DashboardViewModel = hiltViewModel() viewModel: DashboardViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@ -48,6 +53,14 @@ fun DashboardScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") 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( private fun DashboardContent(
data: DashboardData, data: DashboardData,
uiState: DashboardUiState, uiState: DashboardUiState,
onDateRangeSelected: (no.naiv.implausibly.domain.model.DateRange) -> Unit, onDateRangeSelected: (DateRange) -> Unit,
contentPadding: PaddingValues contentPadding: PaddingValues
) { ) {
LazyColumn( LazyColumn(
@ -91,7 +104,6 @@ private fun DashboardContent(
), ),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Date range selector
item { item {
DateRangeSelector( DateRangeSelector(
selected = uiState.dateRange, selected = uiState.dateRange,
@ -99,12 +111,10 @@ private fun DashboardContent(
) )
} }
// Top stats row
item { item {
StatCard(stats = data.topStats) StatCard(stats = data.topStats)
} }
// Visitor chart
item { item {
VisitorChart( VisitorChart(
points = data.timeSeries, points = data.timeSeries,
@ -112,12 +122,10 @@ private fun DashboardContent(
) )
} }
// Top sources
item { item {
TopSourcesSection(entries = data.topSources) TopSourcesSection(entries = data.topSources)
} }
// Top pages
item { item {
TopPagesSection(entries = data.topPages) TopPagesSection(entries = data.topPages)
} }

View file

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

View file

@ -2,23 +2,82 @@
package no.naiv.implausibly.ui.navigation package no.naiv.implausibly.ui.navigation
import androidx.compose.runtime.Composable 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.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument 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.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.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 @Composable
fun AppNavHost() { fun AppNavHost(
appPreferences: AppPreferences,
instanceRepository: InstanceRepository,
siteRepository: SiteRepository
) {
val navController = rememberNavController() 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( NavHost(
navController = navController, 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) { composable(Routes.SETUP) {
SetupScreen( SetupScreen(
onInstanceAdded = { instanceId -> onInstanceAdded = { instanceId ->
@ -35,7 +94,9 @@ fun AppNavHost() {
) { ) {
SiteListScreen( SiteListScreen(
onSiteSelected = { instanceId, siteId -> onSiteSelected = { instanceId, siteId ->
navController.navigate(Routes.dashboard(instanceId, siteId)) navController.navigate(Routes.dashboard(instanceId, siteId)) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}, },
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }
) )
@ -49,7 +110,17 @@ fun AppNavHost() {
) )
) { ) {
DashboardScreen( 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). * Arguments are passed as path parameters (strings only no complex objects).
*/ */
object Routes { object Routes {
const val LOADING = "loading"
const val SETUP = "setup" const val SETUP = "setup"
const val SITE_LIST = "site_list/{instanceId}" const val SITE_LIST = "site_list/{instanceId}"
const val DASHBOARD = "dashboard/{instanceId}/{siteId}" const val DASHBOARD = "dashboard/{instanceId}/{siteId}"