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:
parent
aa66172d58
commit
d9e4b18a52
5 changed files with 114 additions and 13 deletions
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue