From d9e4b18a5262c9c0f19def4fa3479f3e1dc433b0 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 16:50:06 +0100 Subject: [PATCH] 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) --- .../java/no/naiv/implausibly/MainActivity.kt | 15 +++- .../ui/dashboard/DashboardScreen.kt | 20 +++-- .../ui/dashboard/DashboardViewModel.kt | 12 ++- .../implausibly/ui/navigation/AppNavHost.kt | 79 ++++++++++++++++++- .../naiv/implausibly/ui/navigation/Routes.kt | 1 + 5 files changed, 114 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/no/naiv/implausibly/MainActivity.kt b/app/src/main/java/no/naiv/implausibly/MainActivity.kt index c310fdc..ca8b701 100644 --- a/app/src/main/java/no/naiv/implausibly/MainActivity.kt +++ b/app/src/main/java/no/naiv/implausibly/MainActivity.kt @@ -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 + ) } } } 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 062cfd5..a0e4b62 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 @@ -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) } diff --git a/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardViewModel.kt index 3989880..ed22fac 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardViewModel.kt @@ -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 = 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 = _uiState.asStateFlow() init { + // Remember this selection so the app opens here next time + viewModelScope.launch { + appPreferences.setSelectedInstanceId(instanceId) + appPreferences.setSelectedSiteId(siteId) + } loadDashboard() } 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 239d2db..8a6bf69 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 @@ -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 } + } + } ) } } 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 0833f9a..74a064a 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 @@ -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}"