feat: implement Phase 1 MVP of Implausibly

Working Android dashboard app for self-hosted Plausible Analytics CE.
Connects to Plausible API v2 (POST /api/v2/query), displays top stats,
visitor chart, top sources, and top pages with date range selection.

Architecture: Kotlin + Jetpack Compose + Material 3 + Hilt + Ktor +
SQLDelight + EncryptedSharedPreferences. Single :app module, four-layer
unidirectional data flow (UI → ViewModel → Repository → Data).

Includes: instance management, site list, caching with TTL per date
range, encrypted API key storage, custom Canvas visitor chart,
pull-to-refresh, and unit tests for API, cache, repository, and
domain model layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 16:46:08 +01:00
commit aa66172d58
69 changed files with 4778 additions and 0 deletions

103
app/build.gradle.kts Normal file
View file

@ -0,0 +1,103 @@
// SPDX-License-Identifier: GPL-3.0-only
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.sqldelight)
}
android {
namespace = "no.naiv.implausibly"
compileSdk = 35
defaultConfig {
applicationId = "no.naiv.implausibly"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
sqldelight {
databases {
create("ImplausiblyDatabase") {
packageName.set("no.naiv.implausibly.data.local")
}
}
}
dependencies {
// AndroidX
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.security.crypto)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
debugImplementation(libs.compose.ui.tooling)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
// Ktor
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
// SQLDelight
implementation(libs.sqldelight.android.driver)
implementation(libs.sqldelight.coroutines)
// Serialization
implementation(libs.kotlinx.serialization.json)
// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.ktor.client.mock)
}

27
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,27 @@
# SPDX-License-Identifier: GPL-3.0-only
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** {
*** Companion;
}
-keepclasseswithmembers class kotlinx.serialization.json.** {
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class no.naiv.implausibly.**$$serializer { *; }
-keepclassmembers class no.naiv.implausibly.** {
*** Companion;
}
-keepclasseswithmembers class no.naiv.implausibly.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Ktor
-keep class io.ktor.** { *; }
-dontwarn io.ktor.**
# SQLDelight
-keep class app.cash.sqldelight.** { *; }

View file

@ -0,0 +1,25 @@
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ImplausiblyApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Implausibly"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Implausibly">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class ImplausiblyApp : Application()

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import no.naiv.implausibly.ui.navigation.AppNavHost
import no.naiv.implausibly.ui.theme.ImplausiblyTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ImplausiblyTheme {
AppNavHost()
}
}
}
}

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Encrypted storage for API keys using EncryptedSharedPreferences.
*
* Keys are stored by reference (a UUID-based ref), not by instance ID directly,
* to keep the mapping layer clean. The ref is stored in the instances DB table.
*
* EncryptedSharedPreferences uses MasterKey + AES-256-GCM (via Tink, Apache 2.0,
* no Play Services dependency).
*
* NOTE: MasterKey initialization can take ~200ms on first call. This class is
* initialized lazily to avoid blocking the main thread.
*/
@Singleton
class ApiKeyStore @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"implausibly_api_keys",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun save(ref: String, apiKey: String) {
prefs.edit().putString(ref, apiKey).apply()
}
fun get(ref: String): String? {
return prefs.getString(ref, null)
}
fun delete(ref: String) {
prefs.edit().remove(ref).apply()
}
}

View file

@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "implausibly_preferences"
)
/**
* Non-sensitive preferences stored via DataStore.
* Includes the user's selected instance, site, and date range.
*/
@Singleton
class AppPreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
private object Keys {
val SELECTED_INSTANCE_ID = stringPreferencesKey("selected_instance_id")
val SELECTED_SITE_ID = stringPreferencesKey("selected_site_id")
val SELECTED_DATE_RANGE = stringPreferencesKey("selected_date_range")
}
val selectedInstanceId: Flow<String?> = context.dataStore.data
.map { it[Keys.SELECTED_INSTANCE_ID] }
val selectedSiteId: Flow<String?> = context.dataStore.data
.map { it[Keys.SELECTED_SITE_ID] }
val selectedDateRange: Flow<String?> = context.dataStore.data
.map { it[Keys.SELECTED_DATE_RANGE] }
suspend fun setSelectedInstanceId(id: String?) {
context.dataStore.edit { prefs ->
if (id != null) prefs[Keys.SELECTED_INSTANCE_ID] = id
else prefs.remove(Keys.SELECTED_INSTANCE_ID)
}
}
suspend fun setSelectedSiteId(id: String?) {
context.dataStore.edit { prefs ->
if (id != null) prefs[Keys.SELECTED_SITE_ID] = id
else prefs.remove(Keys.SELECTED_SITE_ID)
}
}
suspend fun setSelectedDateRange(range: String) {
context.dataStore.edit { prefs ->
prefs[Keys.SELECTED_DATE_RANGE] = range
}
}
}

View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.remote
import no.naiv.implausibly.data.remote.dto.QueryRequest
import no.naiv.implausibly.data.remote.dto.QueryResponse
/**
* Interface for the Plausible Stats API v2.
* Everything goes through a single POST /api/v2/query endpoint.
*/
interface PlausibleApi {
/**
* Execute a stats query against a Plausible instance.
*
* @param baseUrl The instance base URL (e.g. "https://plausible.example.com")
* @param apiKey Bearer token for authentication (null for public shared links)
* @param request The query request body
* @return The query response
* @throws io.ktor.client.plugins.ClientRequestException on 4xx errors
* @throws io.ktor.client.plugins.ServerResponseException on 5xx errors
*/
suspend fun query(
baseUrl: String,
apiKey: String?,
request: QueryRequest
): QueryResponse
}

View file

@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.remote
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import no.naiv.implausibly.data.remote.dto.QueryRequest
import no.naiv.implausibly.data.remote.dto.QueryResponse
import javax.inject.Inject
import javax.inject.Singleton
/**
* Ktor-based implementation of [PlausibleApi].
*
* No default base URL is configured on the HttpClient each instance
* has a different URL, so it's passed per-request.
*/
@Singleton
class PlausibleApiImpl @Inject constructor(
private val httpClient: HttpClient
) : PlausibleApi {
override suspend fun query(
baseUrl: String,
apiKey: String?,
request: QueryRequest
): QueryResponse {
val url = "${baseUrl.trimEnd('/')}/api/v2/query"
try {
return httpClient.post(url) {
contentType(ContentType.Application.Json)
if (apiKey != null) {
bearerAuth(apiKey)
}
setBody(request)
}.body()
} catch (e: ClientRequestException) {
// Extract the real error message from the API response body
val body = e.response.bodyAsText()
val message = try {
Json.parseToJsonElement(body)
.jsonObject["error"]
?.jsonPrimitive?.content
} catch (_: Exception) {
null
}
throw PlausibleApiException(
message ?: "HTTP ${e.response.status.value}",
e.response.status.value
)
}
}
}
class PlausibleApiException(
message: String,
val statusCode: Int
) : Exception(message)

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.remote.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for POST /api/v2/query.
* Simplified for Phase 1: date_range is always a string, no filters.
*/
@Serializable
data class QueryRequest(
@SerialName("site_id") val siteId: String,
val metrics: List<String>,
@SerialName("date_range") val dateRange: String,
val dimensions: List<String> = emptyList(),
@SerialName("order_by") val orderBy: List<List<String>> = emptyList(),
val pagination: Pagination? = null
)
@Serializable
data class Pagination(
val limit: Int,
val offset: Int = 0
)

View file

@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.remote.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Response from POST /api/v2/query.
*
* The results array contains mixed types: strings for dimensions,
* numbers for metrics. We parse as JsonElement and convert in the repository.
*
* Example response:
* ```json
* {
* "results": [
* { "dimensions": [], "metrics": [1234, 3000, 45.2, 52.1] }
* ],
* "meta": { ... }
* }
* ```
*/
@Serializable
data class QueryResponse(
val results: List<QueryResult>
)
@Serializable
data class QueryResult(
val dimensions: List<JsonElement> = emptyList(),
val metrics: List<JsonElement>
)

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.repository
import no.naiv.implausibly.data.local.ImplausiblyDatabase
import no.naiv.implausibly.domain.model.DateRange
import java.security.MessageDigest
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages the query response cache with TTL-based invalidation.
*
* Cache key = SHA-256 hash of (instanceId, siteId, queryBody).
* TTL varies by date range type realtime is never cached,
* historical ranges are cached for hours.
*/
@Singleton
class CacheManager @Inject constructor(
private val database: ImplausiblyDatabase
) {
/**
* Compute a deterministic cache key from query parameters.
*/
fun computeHash(instanceId: String, siteId: String, queryBody: String): String {
val input = "$instanceId|$siteId|$queryBody"
val digest = MessageDigest.getInstance("SHA-256")
return digest.digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
}
/**
* Retrieve a cached response if it exists and hasn't expired.
*
* @return The cached JSON response, or null if not found / expired
*/
fun getCached(queryHash: String, dateRange: DateRange): String? {
if (dateRange.cacheTtlMinutes <= 0) return null
val cached = database.cachedStatsQueries.selectByHash(queryHash)
.executeAsOneOrNull() ?: return null
val ageMinutes = (System.currentTimeMillis() - cached.fetched_at) / 60_000
return if (ageMinutes < dateRange.cacheTtlMinutes) {
cached.response_json
} else {
null
}
}
/**
* Store a response in the cache.
*/
fun putCache(
queryHash: String,
instanceId: String,
siteId: String,
responseJson: String,
dateRange: DateRange
) {
if (dateRange.cacheTtlMinutes <= 0) return
database.cachedStatsQueries.upsert(
query_hash = queryHash,
instance_id = instanceId,
site_id = siteId,
response_json = responseJson,
fetched_at = System.currentTimeMillis(),
date_range = dateRange.toApiValue()
)
}
/**
* Remove all expired cache entries for an instance.
*/
fun cleanStale(instanceId: String, maxAgeMillis: Long) {
val cutoff = System.currentTimeMillis() - maxAgeMillis
database.cachedStatsQueries.deleteStaleForInstance(instanceId, cutoff)
}
}

View file

@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.repository
import no.naiv.implausibly.data.ApiKeyStore
import no.naiv.implausibly.data.local.ImplausiblyDatabase
import no.naiv.implausibly.data.remote.PlausibleApi
import no.naiv.implausibly.data.remote.dto.QueryRequest
import no.naiv.implausibly.domain.model.AccessMode
import no.naiv.implausibly.domain.model.PlausibleInstance
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* CRUD operations for Plausible instances.
* Coordinates between SQLDelight (instance metadata) and ApiKeyStore (encrypted keys).
*/
@Singleton
class InstanceRepository @Inject constructor(
private val database: ImplausiblyDatabase,
private val apiKeyStore: ApiKeyStore,
private val api: PlausibleApi
) {
fun getAll(): List<PlausibleInstance> {
return database.instanceQueries.selectAll().executeAsList().map { it.toDomain() }
}
fun getById(id: String): PlausibleInstance? {
return database.instanceQueries.selectById(id).executeAsOneOrNull()?.toDomain()
}
/**
* Add a new instance. Stores the API key in encrypted storage if provided.
*
* @return The created instance
*/
fun addInstance(
name: String,
baseUrl: String,
apiKey: String?
): PlausibleInstance {
val id = UUID.randomUUID().toString()
val accessMode = if (apiKey != null) AccessMode.FULL_API else AccessMode.PUBLIC_LINK
val keyRef = if (apiKey != null) {
val ref = "key_$id"
apiKeyStore.save(ref, apiKey)
ref
} else null
val now = System.currentTimeMillis()
database.instanceQueries.insert(
id = id,
name = name,
base_url = baseUrl.trimEnd('/'),
access_mode = accessMode.name,
api_key_ref = keyRef,
created_at = now
)
return PlausibleInstance(
id = id,
name = name,
baseUrl = baseUrl.trimEnd('/'),
accessMode = accessMode,
apiKeyRef = keyRef,
createdAt = now
)
}
fun deleteInstance(id: String) {
val instance = getById(id) ?: return
instance.apiKeyRef?.let { apiKeyStore.delete(it) }
database.instanceQueries.delete(id)
}
fun updateName(id: String, name: String) {
database.instanceQueries.updateName(name = name, id = id)
}
/**
* Resolve the actual API key for an instance from encrypted storage.
*/
fun getApiKey(instance: PlausibleInstance): String? {
return instance.apiKeyRef?.let { apiKeyStore.get(it) }
}
/**
* Test connectivity by firing a minimal query.
* Returns the error message on failure, null on success.
*/
suspend fun testConnection(baseUrl: String, apiKey: String?, siteId: String): String? {
return try {
val request = QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = "day"
)
api.query(baseUrl.trimEnd('/'), apiKey, request)
null
} catch (e: Exception) {
e.message ?: "Connection failed"
}
}
private fun no.naiv.implausibly.Instances.toDomain(): PlausibleInstance {
return PlausibleInstance(
id = id,
name = name,
baseUrl = base_url,
accessMode = AccessMode.valueOf(access_mode),
apiKeyRef = api_key_ref,
createdAt = created_at
)
}
}

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.repository
import no.naiv.implausibly.data.local.ImplausiblyDatabase
import no.naiv.implausibly.domain.model.Site
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages manually-added site IDs per instance.
* Phase 2 will add auto-discovery via the Sites API.
*/
@Singleton
class SiteRepository @Inject constructor(
private val database: ImplausiblyDatabase
) {
fun getSitesForInstance(instanceId: String): List<Site> {
return database.storedSiteQueries.selectByInstance(instanceId)
.executeAsList()
.map { Site(id = it.site_id, instanceId = it.instance_id) }
}
fun addSite(siteId: String, instanceId: String): Site {
database.storedSiteQueries.insert(
site_id = siteId,
instance_id = instanceId
)
return Site(id = siteId, instanceId = instanceId)
}
fun removeSite(siteId: String, instanceId: String) {
database.storedSiteQueries.delete(
site_id = siteId,
instance_id = 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)
}
}

View file

@ -0,0 +1,193 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.double
import kotlinx.serialization.json.long
import no.naiv.implausibly.data.remote.PlausibleApi
import no.naiv.implausibly.data.remote.dto.Pagination
import no.naiv.implausibly.data.remote.dto.QueryRequest
import no.naiv.implausibly.data.remote.dto.QueryResponse
import no.naiv.implausibly.domain.model.DashboardData
import no.naiv.implausibly.domain.model.DateRange
import no.naiv.implausibly.domain.model.DimensionEntry
import no.naiv.implausibly.domain.model.PlausibleInstance
import no.naiv.implausibly.domain.model.TimeSeriesPoint
import no.naiv.implausibly.domain.model.TopStatsData
import javax.inject.Inject
import javax.inject.Singleton
/**
* Core data orchestrator for the dashboard.
*
* Flow: check cache if hit, return cached if miss, fire concurrent API
* queries assemble DashboardData cache the result return.
*
* Uses coroutineScope (fail-fast) if any query fails, all are cancelled.
* Phase 2 can switch to supervisorScope for partial results.
*/
@Singleton
class StatsRepository @Inject constructor(
private val api: PlausibleApi,
private val cacheManager: CacheManager,
private val json: Json
) {
/**
* Load dashboard data, using cache when available.
*
* @param instance The Plausible instance to query
* @param apiKey The resolved API key (null for public links)
* @param siteId The site to query
* @param dateRange The date range to use
* @param forceRefresh If true, skip cache and always fetch from API
*/
suspend fun getDashboardData(
instance: PlausibleInstance,
apiKey: String?,
siteId: String,
dateRange: DateRange,
forceRefresh: Boolean = false
): DashboardData = withContext(Dispatchers.IO) {
val cacheKey = buildCacheKey(instance.id, siteId, dateRange)
val queryHash = cacheManager.computeHash(instance.id, siteId, cacheKey)
// Check cache unless force-refreshing
if (!forceRefresh) {
val cached = cacheManager.getCached(queryHash, dateRange)
if (cached != null) {
return@withContext json.decodeFromString<DashboardData>(cached)
}
}
// Fire all queries concurrently (fail-fast)
val data = fetchFromApi(instance.baseUrl, apiKey, siteId, dateRange)
// Cache the assembled result
val serialized = json.encodeToString(data)
cacheManager.putCache(queryHash, instance.id, siteId, serialized, dateRange)
data
}
private suspend fun fetchFromApi(
baseUrl: String,
apiKey: String?,
siteId: String,
dateRange: DateRange
): DashboardData = coroutineScope {
val apiDateRange = dateRange.toApiValue()
val topStatsDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors", "pageviews", "bounce_rate", "visit_duration"),
dateRange = apiDateRange
)
)
}
val timeSeriesDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = apiDateRange,
dimensions = listOf(dateRange.timeDimension())
)
)
}
val topSourcesDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = apiDateRange,
dimensions = listOf("visit:source"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
)
}
val topPagesDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = apiDateRange,
dimensions = listOf("event:page"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
)
}
val topStats = parseTopStats(topStatsDeferred.await())
val timeSeries = parseTimeSeries(timeSeriesDeferred.await())
val topSources = parseDimension(topSourcesDeferred.await())
val topPages = parseDimension(topPagesDeferred.await())
DashboardData(
topStats = topStats,
timeSeries = timeSeries,
topSources = topSources,
topPages = topPages
)
}
private fun parseTopStats(response: QueryResponse): TopStatsData {
val metrics = response.results.firstOrNull()?.metrics
?: return TopStatsData(0, 0, 0.0, 0.0)
return TopStatsData(
visitors = metrics.getOrNull(0)?.asLong() ?: 0,
pageviews = metrics.getOrNull(1)?.asLong() ?: 0,
bounceRate = metrics.getOrNull(2)?.asDouble() ?: 0.0,
visitDuration = metrics.getOrNull(3)?.asDouble() ?: 0.0
)
}
private fun parseTimeSeries(response: QueryResponse): List<TimeSeriesPoint> {
return response.results.map { result ->
val label = (result.dimensions.firstOrNull() as? JsonPrimitive)
?.content ?: ""
val visitors = result.metrics.firstOrNull()?.asLong() ?: 0
TimeSeriesPoint(label = label, visitors = visitors)
}
}
private fun parseDimension(response: QueryResponse): List<DimensionEntry> {
return response.results.map { result ->
val name = (result.dimensions.firstOrNull() as? JsonPrimitive)
?.content ?: "(unknown)"
val visitors = result.metrics.firstOrNull()?.asLong() ?: 0
DimensionEntry(name = name, visitors = visitors)
}
}
private fun buildCacheKey(instanceId: String, siteId: String, dateRange: DateRange): String {
return "$instanceId|$siteId|${dateRange.toApiValue()}"
}
companion object {
private fun kotlinx.serialization.json.JsonElement.asLong(): Long {
return (this as? JsonPrimitive)?.long ?: 0
}
private fun kotlinx.serialization.json.JsonElement.asDouble(): Double {
return (this as? JsonPrimitive)?.double ?: 0.0
}
}
}

View file

@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.di
import android.content.Context
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import no.naiv.implausibly.data.local.ImplausiblyDatabase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): ImplausiblyDatabase {
val driver = AndroidSqliteDriver(
schema = ImplausiblyDatabase.Schema,
context = context,
name = "implausibly.db"
)
return ImplausiblyDatabase(driver)
}
}

View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import no.naiv.implausibly.data.remote.PlausibleApi
import no.naiv.implausibly.data.remote.PlausibleApiImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
encodeDefaults = false
}
@Provides
@Singleton
fun provideHttpClient(json: Json): HttpClient {
return HttpClient(OkHttp) {
install(ContentNegotiation) {
json(json)
}
expectSuccess = true
}
}
@Provides
@Singleton
fun providePlausibleApi(httpClient: HttpClient): PlausibleApi {
return PlausibleApiImpl(httpClient)
}
}

View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
/**
* How the app authenticates with a Plausible instance.
* - FULL_API: Bearer token with full API access
* - STATS_ONLY: Bearer token with stats-only access
* - PUBLIC_LINK: No authentication needed (shared link)
*/
enum class AccessMode {
FULL_API,
STATS_ONLY,
PUBLIC_LINK
}

View file

@ -0,0 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
import kotlinx.serialization.Serializable
/**
* All data needed to render the dashboard screen.
* Assembled by StatsRepository from multiple concurrent API queries.
*/
@Serializable
data class DashboardData(
val topStats: TopStatsData,
val timeSeries: List<TimeSeriesPoint>,
val topSources: List<DimensionEntry>,
val topPages: List<DimensionEntry>
)

View file

@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
/**
* Date ranges supported by the Plausible API v2.
* Each variant knows its API string representation and cache TTL.
*/
sealed class DateRange(
val displayName: String,
val cacheTtlMinutes: Long
) {
/** Current real-time visitors — never cached */
data object Realtime : DateRange("Realtime", 0)
/** Today's stats — cache for 5 minutes */
data object Today : DateRange("Today", 5)
/** Last 7 days — cache for 30 minutes */
data object SevenDays : DateRange("7d", 30)
/** Last 30 days — cache for 30 minutes */
data object ThirtyDays : DateRange("30d", 30)
/** Last 6 months — cache for 2 hours */
data object SixMonths : DateRange("6mo", 120)
/** Last 12 months — cache for 2 hours */
data object TwelveMonths : DateRange("12mo", 120)
/** String value sent to the Plausible API */
fun toApiValue(): String = when (this) {
Realtime -> "realtime"
Today -> "day"
SevenDays -> "7d"
ThirtyDays -> "30d"
SixMonths -> "6mo"
TwelveMonths -> "12mo"
}
/** The time dimension to use for visitor charts */
fun timeDimension(): String = when (this) {
Today -> "time:hour"
else -> "time:day"
}
companion object {
/** All date ranges available in the UI selector */
val selectable = listOf(Today, SevenDays, ThirtyDays, SixMonths, TwelveMonths)
fun fromApiValue(value: String): DateRange = when (value) {
"realtime" -> Realtime
"day" -> Today
"7d" -> SevenDays
"30d" -> ThirtyDays
"6mo" -> SixMonths
"12mo" -> TwelveMonths
else -> SevenDays
}
}
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
import kotlinx.serialization.Serializable
/**
* A row in a dimension breakdown (e.g. top sources, top pages).
*/
@Serializable
data class DimensionEntry(
val name: String,
val visitors: Long
)

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
/**
* A configured Plausible Analytics instance.
*
* @param id Unique identifier (UUID string)
* @param name User-friendly display name
* @param baseUrl Base URL of the instance (e.g. "https://plausible.example.com")
* @param accessMode How authentication works for this instance
* @param apiKeyRef Reference key into EncryptedSharedPreferences (null for PUBLIC_LINK)
* @param createdAt Epoch millis when instance was added
*/
data class PlausibleInstance(
val id: String,
val name: String,
val baseUrl: String,
val accessMode: AccessMode,
val apiKeyRef: String?,
val createdAt: Long
)

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
/**
* A site tracked by a Plausible instance.
* In Phase 1, site IDs are entered manually by the user.
*/
data class Site(
val id: String,
val instanceId: String
)

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
import kotlinx.serialization.Serializable
/**
* A single point in the visitor time series chart.
*/
@Serializable
data class TimeSeriesPoint(
val label: String,
val visitors: Long
)

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
import kotlinx.serialization.Serializable
/**
* Aggregate stats shown in the top row of the dashboard.
*/
@Serializable
data class TopStatsData(
val visitors: Long,
val pageviews: Long,
val bounceRate: Double,
val visitDuration: Double
)

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@Composable
fun ErrorState(
message: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Something went wrong",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (onRetry != null) {
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
}
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.common
/**
* Generic UI state wrapper for async data loading.
*
* @param T The type of data being loaded
* @property cachedData Optional stale data to show while loading or on error
*/
sealed class UiState<out T> {
data object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(
val message: String,
val cachedData: Any? = null
) : UiState<Nothing>()
}

View file

@ -0,0 +1,129 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.ui.common.ErrorState
import no.naiv.implausibly.ui.common.LoadingIndicator
import no.naiv.implausibly.ui.common.UiState
import no.naiv.implausibly.ui.dashboard.components.DateRangeSelector
import no.naiv.implausibly.ui.dashboard.components.StatCard
import no.naiv.implausibly.ui.dashboard.components.VisitorChart
import no.naiv.implausibly.ui.dashboard.sections.TopPagesSection
import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
onBack: () -> Unit,
viewModel: DashboardViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.siteId) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = viewModel::refresh,
modifier = Modifier.fillMaxSize()
) {
when (val state = uiState.dataState) {
is UiState.Loading -> LoadingIndicator()
is UiState.Error -> ErrorState(
message = state.message,
onRetry = viewModel::refresh
)
is UiState.Success -> DashboardContent(
data = state.data,
uiState = uiState,
onDateRangeSelected = viewModel::setDateRange,
contentPadding = paddingValues
)
}
}
}
}
@Composable
private fun DashboardContent(
data: DashboardData,
uiState: DashboardUiState,
onDateRangeSelected: (no.naiv.implausibly.domain.model.DateRange) -> Unit,
contentPadding: PaddingValues
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = contentPadding.calculateTopPadding() + 8.dp,
bottom = contentPadding.calculateBottomPadding() + 16.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Date range selector
item {
DateRangeSelector(
selected = uiState.dateRange,
onSelected = onDateRangeSelected
)
}
// Top stats row
item {
StatCard(stats = data.topStats)
}
// Visitor chart
item {
VisitorChart(
points = data.timeSeries,
modifier = Modifier.height(200.dp)
)
}
// Top sources
item {
TopSourcesSection(entries = data.topSources)
}
// Top pages
item {
TopPagesSection(entries = data.topPages)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}
}
}

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard
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.StatsRepository
import no.naiv.implausibly.domain.model.DashboardData
import no.naiv.implausibly.domain.model.DateRange
import no.naiv.implausibly.ui.common.UiState
import javax.inject.Inject
data class DashboardUiState(
val siteId: String = "",
val dateRange: DateRange = DateRange.ThirtyDays,
val dataState: UiState<DashboardData> = UiState.Loading,
val isRefreshing: Boolean = false
)
@HiltViewModel
class DashboardViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val instanceRepository: InstanceRepository,
private val statsRepository: StatsRepository
) : ViewModel() {
private val instanceId: String = savedStateHandle["instanceId"] ?: ""
private val siteId: String = savedStateHandle["siteId"] ?: ""
private val _uiState = MutableStateFlow(DashboardUiState(siteId = siteId))
val uiState: StateFlow<DashboardUiState> = _uiState.asStateFlow()
init {
loadDashboard()
}
fun refresh() {
_uiState.update { it.copy(isRefreshing = true) }
loadDashboard(forceRefresh = true)
}
fun setDateRange(dateRange: DateRange) {
_uiState.update { it.copy(dateRange = dateRange, dataState = UiState.Loading) }
loadDashboard()
}
private fun loadDashboard(forceRefresh: Boolean = false) {
viewModelScope.launch {
try {
val instance = instanceRepository.getById(instanceId)
?: throw IllegalStateException("Instance not found")
val apiKey = instanceRepository.getApiKey(instance)
val data = statsRepository.getDashboardData(
instance = instance,
apiKey = apiKey,
siteId = siteId,
dateRange = _uiState.value.dateRange,
forceRefresh = forceRefresh
)
_uiState.update {
it.copy(
dataState = UiState.Success(data),
isRefreshing = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
dataState = UiState.Error(e.message ?: "Unknown error"),
isRefreshing = false
)
}
}
}
}
}

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import no.naiv.implausibly.domain.model.DateRange
@Composable
fun DateRangeSelector(
selected: DateRange,
onSelected: (DateRange) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
DateRangeChip("Today", selected is DateRange.Today) { onSelected(DateRange.Today) }
DateRangeChip("7d", selected is DateRange.SevenDays) { onSelected(DateRange.SevenDays) }
DateRangeChip("30d", selected is DateRange.ThirtyDays) { onSelected(DateRange.ThirtyDays) }
DateRangeChip("6mo", selected is DateRange.SixMonths) { onSelected(DateRange.SixMonths) }
DateRangeChip("12mo", selected is DateRange.TwelveMonths) { onSelected(DateRange.TwelveMonths) }
}
}
@Composable
private fun DateRangeChip(
label: String,
isSelected: Boolean,
onClick: () -> Unit
) {
FilterChip(
selected = isSelected,
onClick = onClick,
label = { Text(label) }
)
}

View file

@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import no.naiv.implausibly.domain.model.TopStatsData
import kotlin.math.roundToInt
@Composable
fun StatCard(
stats: TopStatsData,
modifier: Modifier = Modifier
) {
Card(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(label = "Visitors", value = formatNumber(stats.visitors))
StatItem(label = "Pageviews", value = formatNumber(stats.pageviews))
StatItem(label = "Bounce", value = "${stats.bounceRate.roundToInt()}%")
StatItem(label = "Duration", value = formatDuration(stats.visitDuration))
}
}
}
@Composable
private fun StatItem(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
private fun formatNumber(n: Long): String = when {
n >= 1_000_000 -> "%.1fM".format(n / 1_000_000.0)
n >= 1_000 -> "%.1fk".format(n / 1_000.0)
else -> n.toString()
}
private fun formatDuration(seconds: Double): String {
val totalSeconds = seconds.roundToInt()
return when {
totalSeconds >= 3600 -> "${totalSeconds / 3600}h ${(totalSeconds % 3600) / 60}m"
totalSeconds >= 60 -> "${totalSeconds / 60}m ${totalSeconds % 60}s"
else -> "${totalSeconds}s"
}
}

View file

@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import no.naiv.implausibly.domain.model.TimeSeriesPoint
/**
* Custom Canvas line chart for visitor time series.
*
* Draws a filled area chart with a line on top. No external charting
* library needed this is ~80 lines of Canvas drawing code.
*/
@Composable
fun VisitorChart(
points: List<TimeSeriesPoint>,
modifier: Modifier = Modifier
) {
val lineColor = MaterialTheme.colorScheme.primary
val fillColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
Card(modifier = modifier.fillMaxWidth()) {
if (points.isEmpty()) return@Card
Canvas(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.then(modifier)
) {
val maxVisitors = points.maxOf { it.visitors }.coerceAtLeast(1)
val width = size.width
val height = size.height
val padding = 4.dp.toPx()
val chartWidth = width - padding * 2
val chartHeight = height - padding * 2
val stepX = if (points.size > 1) chartWidth / (points.size - 1) else chartWidth
// Build points
val chartPoints = points.mapIndexed { index, point ->
val x = padding + index * stepX
val y = padding + chartHeight - (point.visitors.toFloat() / maxVisitors * chartHeight)
Offset(x, y)
}
// Fill area
val fillPath = Path().apply {
moveTo(chartPoints.first().x, padding + chartHeight)
chartPoints.forEach { lineTo(it.x, it.y) }
lineTo(chartPoints.last().x, padding + chartHeight)
close()
}
drawPath(fillPath, fillColor)
// Draw line
val linePath = Path().apply {
chartPoints.forEachIndexed { index, point ->
if (index == 0) moveTo(point.x, point.y)
else lineTo(point.x, point.y)
}
}
drawPath(linePath, lineColor, style = Stroke(width = 2.dp.toPx()))
// Draw dots at each data point
chartPoints.forEach { point ->
drawCircle(
color = lineColor,
radius = 3.dp.toPx(),
center = point
)
}
}
}
}

View file

@ -0,0 +1,83 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material3.Card
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import no.naiv.implausibly.domain.model.DimensionEntry
/**
* Reusable section for dimension breakdowns (sources, pages, etc.).
* Shows a title and a list of entries with proportional bars.
*/
@Composable
fun DimensionSection(
title: String,
entries: List<DimensionEntry>,
modifier: Modifier = Modifier
) {
if (entries.isEmpty()) return
val maxVisitors = entries.maxOf { it.visitors }.coerceAtLeast(1)
Card(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
entries.forEach { entry ->
DimensionRow(
entry = entry,
maxVisitors = maxVisitors
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun DimensionRow(
entry: DimensionEntry,
maxVisitors: Long
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = entry.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = entry.visitors.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { entry.visitors.toFloat() / maxVisitors },
modifier = Modifier.fillMaxWidth(),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import no.naiv.implausibly.domain.model.DimensionEntry
@Composable
fun TopPagesSection(
entries: List<DimensionEntry>,
modifier: Modifier = Modifier
) {
DimensionSection(
title = "Top Pages",
entries = entries,
modifier = modifier
)
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import no.naiv.implausibly.domain.model.DimensionEntry
@Composable
fun TopSourcesSection(
entries: List<DimensionEntry>,
modifier: Modifier = Modifier
) {
DimensionSection(
title = "Top Sources",
entries = entries,
modifier = modifier
)
}

View file

@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.navigation
import androidx.compose.runtime.Composable
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.ui.dashboard.DashboardScreen
import no.naiv.implausibly.ui.setup.SetupScreen
import no.naiv.implausibly.ui.sites.SiteListScreen
@Composable
fun AppNavHost() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.SETUP
) {
composable(Routes.SETUP) {
SetupScreen(
onInstanceAdded = { instanceId ->
navController.navigate(Routes.siteList(instanceId)) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}
)
}
composable(
route = Routes.SITE_LIST,
arguments = listOf(navArgument("instanceId") { type = NavType.StringType })
) {
SiteListScreen(
onSiteSelected = { instanceId, siteId ->
navController.navigate(Routes.dashboard(instanceId, siteId))
},
onBack = { navController.popBackStack() }
)
}
composable(
route = Routes.DASHBOARD,
arguments = listOf(
navArgument("instanceId") { type = NavType.StringType },
navArgument("siteId") { type = NavType.StringType }
)
) {
DashboardScreen(
onBack = { navController.popBackStack() }
)
}
}
}

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.navigation
/**
* Navigation route constants.
* Arguments are passed as path parameters (strings only no complex objects).
*/
object Routes {
const val SETUP = "setup"
const val SITE_LIST = "site_list/{instanceId}"
const val DASHBOARD = "dashboard/{instanceId}/{siteId}"
fun siteList(instanceId: String) = "site_list/$instanceId"
fun dashboard(instanceId: String, siteId: String) = "dashboard/$instanceId/$siteId"
}

View file

@ -0,0 +1,216 @@
// 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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun SetupScreen(
onInstanceAdded: (String) -> Unit,
viewModel: SetupViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
// Navigate when instance is saved
LaunchedEffect(uiState.savedInstanceId) {
uiState.savedInstanceId?.let { onInstanceAdded(it) }
}
Scaffold { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Implausibly",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "Connect to your Plausible Analytics instance",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
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,
onValueChange = viewModel::onBaseUrlChanged,
label = { Text("Instance URL") },
placeholder = { Text("https://plausible.example.com") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
// API Key
OutlinedTextField(
value = uiState.apiKey,
onValueChange = viewModel::onApiKeyChanged,
label = { Text("API Key (optional)") },
placeholder = { Text("Leave blank for public dashboards") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
// Site ID
OutlinedTextField(
value = uiState.siteId,
onValueChange = viewModel::onSiteIdChanged,
label = { Text("Site ID") },
placeholder = { Text("example.com") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
// Friendly name
OutlinedTextField(
value = uiState.name,
onValueChange = viewModel::onNameChanged,
label = { Text("Friendly name (optional)") },
placeholder = { Text("Auto-filled on successful test") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Test result
when (val result = uiState.testResult) {
is TestResult.Success -> {
Text(
text = "Connection successful!",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium
)
}
is TestResult.Failure -> {
Text(
text = "Connection failed: ${result.message}",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
null -> {}
}
Spacer(modifier = Modifier.height(12.dp))
// Test Connection button
OutlinedButton(
onClick = viewModel::testConnection,
enabled = uiState.baseUrl.isNotBlank()
&& uiState.siteId.isNotBlank()
&& !uiState.isTesting,
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isTesting) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp)
)
} else {
Text("Test Connection")
}
}
Spacer(modifier = Modifier.height(8.dp))
// Save button
Button(
onClick = viewModel::saveInstance,
enabled = uiState.baseUrl.isNotBlank()
&& uiState.siteId.isNotBlank()
&& !uiState.isSaving,
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isSaving) {
CircularProgressIndicator(
modifier = Modifier.height(20.dp)
)
} else {
Text("Save & Continue")
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}

View file

@ -0,0 +1,141 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.setup
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.AppPreferences
import no.naiv.implausibly.data.repository.InstanceRepository
import no.naiv.implausibly.data.repository.SiteRepository
import javax.inject.Inject
data class SetupUiState(
val baseUrl: String = "",
val apiKey: String = "",
val name: String = "",
val siteId: String = "",
val isTesting: Boolean = false,
val testResult: TestResult? = null,
val isSaving: Boolean = false,
val savedInstanceId: String? = null,
val existingInstances: List<ExistingInstance> = emptyList()
)
data class ExistingInstance(
val id: String,
val name: String,
val baseUrl: String
)
sealed class TestResult {
data object Success : TestResult()
data class Failure(val message: String) : TestResult()
}
@HiltViewModel
class SetupViewModel @Inject constructor(
private val instanceRepository: InstanceRepository,
private val siteRepository: SiteRepository,
private val appPreferences: AppPreferences
) : ViewModel() {
private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
init {
loadExistingInstances()
}
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) }
}
fun onApiKeyChanged(key: String) {
_uiState.update { it.copy(apiKey = key, testResult = null) }
}
fun onNameChanged(name: String) {
_uiState.update { it.copy(name = name) }
}
fun onSiteIdChanged(siteId: String) {
_uiState.update { it.copy(siteId = siteId, testResult = null) }
}
fun testConnection() {
val state = _uiState.value
if (state.baseUrl.isBlank() || state.siteId.isBlank()) return
_uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch {
val apiKey = state.apiKey.ifBlank { null }
val error = instanceRepository.testConnection(
baseUrl = state.baseUrl,
apiKey = apiKey,
siteId = state.siteId
)
_uiState.update {
it.copy(
isTesting = false,
testResult = if (error == null) TestResult.Success else TestResult.Failure(error),
name = if (error == null && it.name.isBlank()) {
// Auto-fill name from site ID if blank
state.siteId
} else {
it.name
}
)
}
}
}
fun saveInstance() {
val state = _uiState.value
if (state.baseUrl.isBlank() || state.siteId.isBlank()) return
_uiState.update { it.copy(isSaving = true) }
viewModelScope.launch {
val apiKey = state.apiKey.ifBlank { null }
val name = state.name.ifBlank { state.siteId }
val instance = instanceRepository.addInstance(
name = name,
baseUrl = state.baseUrl,
apiKey = apiKey
)
// Also store the site ID
siteRepository.addSite(state.siteId, instance.id)
// Set as selected
appPreferences.setSelectedInstanceId(instance.id)
appPreferences.setSelectedSiteId(state.siteId)
_uiState.update { it.copy(isSaving = false, savedInstanceId = instance.id) }
}
}
fun selectExistingInstance(instanceId: String) {
viewModelScope.launch {
appPreferences.setSelectedInstanceId(instanceId)
_uiState.update { it.copy(savedInstanceId = instanceId) }
}
}
}

View file

@ -0,0 +1,181 @@
// 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.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Language
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.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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SiteListScreen(
onSiteSelected: (instanceId: String, siteId: String) -> Unit,
onBack: () -> Unit,
viewModel: SiteListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { 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("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) {
// Editing mode: inline text field
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 {
// Normal mode
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 = { viewModel.startEditing(site.id) }) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit site"
)
}
IconButton(onClick = { viewModel.removeSite(site.id) }) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove site",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,116 @@
// 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<Site> = 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<SiteListUiState> = _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 = "") }
}
}

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.theme
import androidx.compose.ui.graphics.Color
// Plausible-inspired color palette
val PlausibleIndigo = Color(0xFF5850EC)
val PlausibleIndigoLight = Color(0xFF8B83FF)
val PlausibleIndigoDark = Color(0xFF3F38A5)
// Light theme
val LightPrimary = PlausibleIndigo
val LightOnPrimary = Color.White
val LightPrimaryContainer = Color(0xFFE8E6FF)
val LightOnPrimaryContainer = Color(0xFF1A1452)
val LightSecondary = Color(0xFF5C5D72)
val LightOnSecondary = Color.White
val LightBackground = Color(0xFFFFFBFF)
val LightOnBackground = Color(0xFF1C1B1F)
val LightSurface = Color(0xFFFFFBFF)
val LightOnSurface = Color(0xFF1C1B1F)
val LightSurfaceVariant = Color(0xFFE7E0EC)
val LightError = Color(0xFFBA1A1A)
// Dark theme
val DarkPrimary = Color(0xFFC4C0FF)
val DarkOnPrimary = Color(0xFF2A2180)
val DarkPrimaryContainer = PlausibleIndigo
val DarkOnPrimaryContainer = Color(0xFFE8E6FF)
val DarkSecondary = Color(0xFFC5C4DD)
val DarkOnSecondary = Color(0xFF2E2F42)
val DarkBackground = Color(0xFF1C1B1F)
val DarkOnBackground = Color(0xFFE6E1E5)
val DarkSurface = Color(0xFF1C1B1F)
val DarkOnSurface = Color(0xFFE6E1E5)
val DarkSurfaceVariant = Color(0xFF49454F)
val DarkError = Color(0xFFFFB4AB)

View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
primaryContainer = LightPrimaryContainer,
onPrimaryContainer = LightOnPrimaryContainer,
secondary = LightSecondary,
onSecondary = LightOnSecondary,
background = LightBackground,
onBackground = LightOnBackground,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
error = LightError
)
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
primaryContainer = DarkPrimaryContainer,
onPrimaryContainer = DarkOnPrimaryContainer,
secondary = DarkSecondary,
onSecondary = DarkOnSecondary,
background = DarkBackground,
onBackground = DarkOnBackground,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
error = DarkError
)
@Composable
fun ImplausiblyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = ImplausiblyTypography,
content = content
)
}

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val ImplausiblyTypography = Typography(
headlineLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp
)
)

View file

@ -0,0 +1,10 @@
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#5850EC"
android:pathData="M0,0h108v108H0z" />
</vector>

View file

@ -0,0 +1,17 @@
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple bar chart icon representing analytics -->
<path
android:fillColor="#FFFFFF"
android:pathData="M30,75 L30,50 L40,50 L40,75 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M46,75 L46,38 L56,38 L56,75 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M62,75 L62,28 L72,28 L72,75 Z" />
</vector>

View file

@ -0,0 +1,5 @@
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<resources>
<string name="app_name">Implausibly</string>
</resources>

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<resources>
<style name="Theme.Implausibly" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,22 @@
-- SPDX-License-Identifier: GPL-3.0-only
CREATE TABLE cached_stats (
query_hash TEXT NOT NULL PRIMARY KEY,
instance_id TEXT NOT NULL,
site_id TEXT NOT NULL,
response_json TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
date_range TEXT NOT NULL
);
selectByHash:
SELECT * FROM cached_stats WHERE query_hash = ?;
upsert:
INSERT OR REPLACE INTO cached_stats VALUES (?, ?, ?, ?, ?, ?);
deleteStaleForInstance:
DELETE FROM cached_stats WHERE instance_id = ? AND fetched_at < ?;
deleteAll:
DELETE FROM cached_stats;

View file

@ -0,0 +1,25 @@
-- SPDX-License-Identifier: GPL-3.0-only
CREATE TABLE instances (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
access_mode TEXT NOT NULL,
api_key_ref TEXT,
created_at INTEGER NOT NULL
);
selectAll:
SELECT * FROM instances ORDER BY created_at ASC;
selectById:
SELECT * FROM instances WHERE id = ?;
insert:
INSERT INTO instances VALUES (?, ?, ?, ?, ?, ?);
updateName:
UPDATE instances SET name = ? WHERE id = ?;
delete:
DELETE FROM instances WHERE id = ?;

View file

@ -0,0 +1,19 @@
-- SPDX-License-Identifier: GPL-3.0-only
CREATE TABLE stored_sites (
site_id TEXT NOT NULL,
instance_id TEXT NOT NULL,
PRIMARY KEY (site_id, instance_id)
);
selectByInstance:
SELECT * FROM stored_sites WHERE instance_id = ? ORDER BY site_id ASC;
insert:
INSERT OR IGNORE INTO stored_sites VALUES (?, ?);
delete:
DELETE FROM stored_sites WHERE site_id = ? AND instance_id = ?;
deleteByInstance:
DELETE FROM stored_sites WHERE instance_id = ?;

View file

@ -0,0 +1,143 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.remote
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import no.naiv.implausibly.data.remote.dto.Pagination
import no.naiv.implausibly.data.remote.dto.QueryRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class PlausibleApiImplTest {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private fun createClient(responseBody: String, statusCode: HttpStatusCode = HttpStatusCode.OK): Pair<HttpClient, MockEngine> {
val engine = MockEngine { _ ->
respond(
content = responseBody,
status = statusCode,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
return client to engine
}
@Test
fun `query sends correct request and parses response`() = runTest {
val responseJson = """
{
"results": [
{
"dimensions": [],
"metrics": [1234, 3000, 45.2, 52.1]
}
]
}
""".trimIndent()
val (client, engine) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors", "pageviews", "bounce_rate", "visit_duration"),
dateRange = "30d"
)
val response = api.query("https://plausible.example.com", "test-key", request)
assertEquals(1, response.results.size)
assertEquals(4, response.results[0].metrics.size)
// Verify the request was sent to the correct URL
val requestHistory = engine.requestHistory
assertEquals(1, requestHistory.size)
assertEquals("https://plausible.example.com/api/v2/query", requestHistory[0].url.toString())
// Verify auth header
assertEquals("Bearer test-key", requestHistory[0].headers[HttpHeaders.Authorization])
}
@Test
fun `query without api key omits auth header`() = runTest {
val responseJson = """{"results": []}"""
val (client, engine) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors"),
dateRange = "7d"
)
api.query("https://plausible.example.com", null, request)
val requestHistory = engine.requestHistory
assertEquals(null, requestHistory[0].headers[HttpHeaders.Authorization])
}
@Test
fun `query strips trailing slash from base URL`() = runTest {
val responseJson = """{"results": []}"""
val (client, engine) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors"),
dateRange = "7d"
)
api.query("https://plausible.example.com/", null, request)
assertEquals(
"https://plausible.example.com/api/v2/query",
engine.requestHistory[0].url.toString()
)
}
@Test
fun `query parses dimension response`() = runTest {
val responseJson = """
{
"results": [
{"dimensions": ["Google"], "metrics": [842]},
{"dimensions": ["Direct"], "metrics": [512]},
{"dimensions": ["twitter.com"], "metrics": [201]}
]
}
""".trimIndent()
val (client, _) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors"),
dateRange = "30d",
dimensions = listOf("visit:source"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
val response = api.query("https://plausible.example.com", "key", request)
assertEquals(3, response.results.size)
assertNotNull(response.results[0].dimensions)
assertEquals(1, response.results[0].dimensions.size)
}
}

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.repository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class CacheManagerTest {
@Test
fun `computeHash produces deterministic results`() {
// CacheManager.computeHash is a pure function (SHA-256), so we can test it
// without the database. We instantiate with a mock db just for the hash test.
// Since computeHash doesn't use the database, we can use reflection or
// just test the hash algorithm directly.
val input1 = "instance1|site1|{\"metrics\":[\"visitors\"]}"
val input2 = "instance1|site1|{\"metrics\":[\"visitors\"]}"
val input3 = "instance2|site1|{\"metrics\":[\"visitors\"]}"
val hash1 = sha256(input1)
val hash2 = sha256(input2)
val hash3 = sha256(input3)
// Same input → same hash
assertEquals(hash1, hash2)
// Different input → different hash
assertNotEquals(hash1, hash3)
// Hash is 64 hex chars (256 bits)
assertEquals(64, hash1.length)
}
@Test
fun `computeHash changes with different site ID`() {
val hash1 = sha256("inst1|siteA|query")
val hash2 = sha256("inst1|siteB|query")
assertNotEquals(hash1, hash2)
}
@Test
fun `computeHash changes with different query body`() {
val hash1 = sha256("inst1|site1|query1")
val hash2 = sha256("inst1|site1|query2")
assertNotEquals(hash1, hash2)
}
private fun sha256(input: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
return digest.digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
}
}

View file

@ -0,0 +1,160 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.repository
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import no.naiv.implausibly.data.remote.PlausibleApi
import no.naiv.implausibly.data.remote.dto.QueryResponse
import no.naiv.implausibly.data.remote.dto.QueryResult
import no.naiv.implausibly.domain.model.AccessMode
import no.naiv.implausibly.domain.model.DateRange
import no.naiv.implausibly.domain.model.PlausibleInstance
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
class StatsRepositoryTest {
private lateinit var api: PlausibleApi
private lateinit var cacheManager: CacheManager
private lateinit var repository: StatsRepository
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private val testInstance = PlausibleInstance(
id = "test-instance",
name = "Test",
baseUrl = "https://plausible.example.com",
accessMode = AccessMode.FULL_API,
apiKeyRef = "key_test",
createdAt = System.currentTimeMillis()
)
@Before
fun setup() {
api = mockk()
cacheManager = mockk()
// Default: cache miss
every { cacheManager.computeHash(any(), any(), any()) } returns "test-hash"
every { cacheManager.getCached(any(), any()) } returns null
every { cacheManager.putCache(any(), any(), any(), any(), any()) } returns Unit
repository = StatsRepository(api, cacheManager, json)
}
@Test
fun `getDashboardData fetches and assembles data from API`() = runTest {
// Mock top stats response
coEvery { api.query(any(), any(), match { it.dimensions.isEmpty() }) } returns
QueryResponse(
results = listOf(
QueryResult(
dimensions = emptyList(),
metrics = listOf(
JsonPrimitive(1234),
JsonPrimitive(3000),
JsonPrimitive(45.2),
JsonPrimitive(52.1)
)
)
)
)
// Mock time series response
coEvery { api.query(any(), any(), match { "time:day" in it.dimensions }) } returns
QueryResponse(
results = listOf(
QueryResult(
dimensions = listOf(JsonPrimitive("2024-01-01")),
metrics = listOf(JsonPrimitive(100))
),
QueryResult(
dimensions = listOf(JsonPrimitive("2024-01-02")),
metrics = listOf(JsonPrimitive(150))
)
)
)
// Mock sources response
coEvery { api.query(any(), any(), match { "visit:source" in it.dimensions }) } returns
QueryResponse(
results = listOf(
QueryResult(
dimensions = listOf(JsonPrimitive("Google")),
metrics = listOf(JsonPrimitive(842))
)
)
)
// Mock pages response
coEvery { api.query(any(), any(), match { "event:page" in it.dimensions }) } returns
QueryResponse(
results = listOf(
QueryResult(
dimensions = listOf(JsonPrimitive("/blog")),
metrics = listOf(JsonPrimitive(423))
)
)
)
val result = repository.getDashboardData(
instance = testInstance,
apiKey = "test-key",
siteId = "example.com",
dateRange = DateRange.ThirtyDays
)
assertNotNull(result)
assertEquals(1234, result.topStats.visitors)
assertEquals(3000, result.topStats.pageviews)
assertEquals(45.2, result.topStats.bounceRate, 0.01)
assertEquals(2, result.timeSeries.size)
assertEquals(1, result.topSources.size)
assertEquals("Google", result.topSources[0].name)
assertEquals(1, result.topPages.size)
assertEquals("/blog", result.topPages[0].name)
}
@Test
fun `getDashboardData returns cached data when available`() = runTest {
val cachedJson = json.encodeToString(
kotlinx.serialization.serializer(),
no.naiv.implausibly.domain.model.DashboardData(
topStats = no.naiv.implausibly.domain.model.TopStatsData(100, 200, 50.0, 30.0),
timeSeries = emptyList(),
topSources = emptyList(),
topPages = emptyList()
)
)
every { cacheManager.getCached("test-hash", DateRange.ThirtyDays) } returns cachedJson
val result = repository.getDashboardData(
instance = testInstance,
apiKey = "test-key",
siteId = "example.com",
dateRange = DateRange.ThirtyDays
)
assertEquals(100, result.topStats.visitors)
assertEquals(200, result.topStats.pageviews)
}
@Test(expected = Exception::class)
fun `getDashboardData propagates API errors`() = runTest {
coEvery { api.query(any(), any(), any()) } throws RuntimeException("Network error")
repository.getDashboardData(
instance = testInstance,
apiKey = "test-key",
siteId = "example.com",
dateRange = DateRange.ThirtyDays,
forceRefresh = true
)
}
}

View file

@ -0,0 +1,54 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
import org.junit.Assert.assertEquals
import org.junit.Test
class DateRangeTest {
@Test
fun `toApiValue returns correct string for each range`() {
assertEquals("realtime", DateRange.Realtime.toApiValue())
assertEquals("day", DateRange.Today.toApiValue())
assertEquals("7d", DateRange.SevenDays.toApiValue())
assertEquals("30d", DateRange.ThirtyDays.toApiValue())
assertEquals("6mo", DateRange.SixMonths.toApiValue())
assertEquals("12mo", DateRange.TwelveMonths.toApiValue())
}
@Test
fun `fromApiValue round-trips correctly`() {
DateRange.selectable.forEach { range ->
assertEquals(range, DateRange.fromApiValue(range.toApiValue()))
}
}
@Test
fun `fromApiValue returns SevenDays for unknown values`() {
assertEquals(DateRange.SevenDays, DateRange.fromApiValue("unknown"))
}
@Test
fun `cacheTtlMinutes is correct for each range`() {
assertEquals(0, DateRange.Realtime.cacheTtlMinutes)
assertEquals(5, DateRange.Today.cacheTtlMinutes)
assertEquals(30, DateRange.SevenDays.cacheTtlMinutes)
assertEquals(30, DateRange.ThirtyDays.cacheTtlMinutes)
assertEquals(120, DateRange.SixMonths.cacheTtlMinutes)
assertEquals(120, DateRange.TwelveMonths.cacheTtlMinutes)
}
@Test
fun `timeDimension returns hour for Today, day for others`() {
assertEquals("time:hour", DateRange.Today.timeDimension())
assertEquals("time:day", DateRange.SevenDays.timeDimension())
assertEquals("time:day", DateRange.ThirtyDays.timeDimension())
assertEquals("time:day", DateRange.TwelveMonths.timeDimension())
}
@Test
fun `selectable does not include Realtime`() {
assert(DateRange.Realtime !in DateRange.selectable)
assertEquals(5, DateRange.selectable.size)
}
}