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:
commit
aa66172d58
69 changed files with 4778 additions and 0 deletions
103
app/build.gradle.kts
Normal file
103
app/build.gradle.kts
Normal 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
27
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||
25
app/src/main/AndroidManifest.xml
Normal file
25
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
8
app/src/main/java/no/naiv/implausibly/ImplausiblyApp.kt
Normal file
8
app/src/main/java/no/naiv/implausibly/ImplausiblyApp.kt
Normal 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()
|
||||
23
app/src/main/java/no/naiv/implausibly/MainActivity.kt
Normal file
23
app/src/main/java/no/naiv/implausibly/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/src/main/java/no/naiv/implausibly/data/ApiKeyStore.kt
Normal file
53
app/src/main/java/no/naiv/implausibly/data/ApiKeyStore.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
62
app/src/main/java/no/naiv/implausibly/data/AppPreferences.kt
Normal file
62
app/src/main/java/no/naiv/implausibly/data/AppPreferences.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/src/main/java/no/naiv/implausibly/di/DatabaseModule.kt
Normal file
28
app/src/main/java/no/naiv/implausibly/di/DatabaseModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
46
app/src/main/java/no/naiv/implausibly/di/NetworkModule.kt
Normal file
46
app/src/main/java/no/naiv/implausibly/di/NetworkModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
11
app/src/main/java/no/naiv/implausibly/domain/model/Site.kt
Normal file
11
app/src/main/java/no/naiv/implausibly/domain/model/Site.kt
Normal 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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/no/naiv/implausibly/ui/common/UiState.kt
Normal file
19
app/src/main/java/no/naiv/implausibly/ui/common/UiState.kt
Normal 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>()
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
216
app/src/main/java/no/naiv/implausibly/ui/setup/SetupScreen.kt
Normal file
216
app/src/main/java/no/naiv/implausibly/ui/setup/SetupScreen.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
141
app/src/main/java/no/naiv/implausibly/ui/setup/SetupViewModel.kt
Normal file
141
app/src/main/java/no/naiv/implausibly/ui/setup/SetupViewModel.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
181
app/src/main/java/no/naiv/implausibly/ui/sites/SiteListScreen.kt
Normal file
181
app/src/main/java/no/naiv/implausibly/ui/sites/SiteListScreen.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = "") }
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/no/naiv/implausibly/ui/theme/Color.kt
Normal file
37
app/src/main/java/no/naiv/implausibly/ui/theme/Color.kt
Normal 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)
|
||||
64
app/src/main/java/no/naiv/implausibly/ui/theme/Theme.kt
Normal file
64
app/src/main/java/no/naiv/implausibly/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
50
app/src/main/java/no/naiv/implausibly/ui/theme/Type.kt
Normal file
50
app/src/main/java/no/naiv/implausibly/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
)
|
||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
17
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
17
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
<resources>
|
||||
<string name="app_name">Implausibly</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!-- SPDX-License-Identifier: GPL-3.0-only -->
|
||||
<resources>
|
||||
<style name="Theme.Implausibly" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
22
app/src/main/sqldelight/no/naiv/implausibly/CachedStats.sq
Normal file
22
app/src/main/sqldelight/no/naiv/implausibly/CachedStats.sq
Normal 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;
|
||||
25
app/src/main/sqldelight/no/naiv/implausibly/Instance.sq
Normal file
25
app/src/main/sqldelight/no/naiv/implausibly/Instance.sq
Normal 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 = ?;
|
||||
19
app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq
Normal file
19
app/src/main/sqldelight/no/naiv/implausibly/StoredSite.sq
Normal 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 = ?;
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue