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
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Android
|
||||||
|
local.properties
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ap_
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude/
|
||||||
105
CLAUDE.md
Normal file
105
CLAUDE.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Implausibly** — an open-source Android dashboard app for self-hosted Plausible Analytics CE.
|
||||||
|
- **Package:** `no.naiv.implausibly`
|
||||||
|
- **Stack:** Kotlin, Jetpack Compose, Material 3
|
||||||
|
- **License:** GPL-3.0-only
|
||||||
|
- **Target:** API 26+ (Android 8.0)
|
||||||
|
- **Distribution:** F-Droid (primary), Forgejo Releases on kode.naiv.no — no Google Play
|
||||||
|
|
||||||
|
## Build & Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build (requires gradle wrapper JAR — run once from a machine with Gradle installed)
|
||||||
|
gradle wrapper --gradle-version 8.10.2
|
||||||
|
|
||||||
|
# Standard build/test commands
|
||||||
|
./gradlew assembleDebug
|
||||||
|
./gradlew test # Unit tests
|
||||||
|
./gradlew connectedAndroidTest # Instrumented tests
|
||||||
|
./gradlew lint # Android lint
|
||||||
|
./gradlew :app:dependencies # Audit dependency tree (must be free of Play Services)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Four-layer architecture with unidirectional data flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
UI (Jetpack Compose + Material 3)
|
||||||
|
↓ observes StateFlow
|
||||||
|
ViewModel (per screen, sealed UiState classes)
|
||||||
|
↓ calls
|
||||||
|
Repository (single source of truth, coordinates API + cache)
|
||||||
|
↓ delegates to
|
||||||
|
Remote (Ktor) + Local Cache (SQLDelight)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key technology choices
|
||||||
|
| Component | Library | Rationale |
|
||||||
|
|-----------|---------|-----------|
|
||||||
|
| HTTP | **Ktor** | Pure Kotlin, no annotation processing; we only call one endpoint |
|
||||||
|
| Serialization | **kotlinx.serialization** | Compiler plugin, no reflection |
|
||||||
|
| Local DB | **SQLDelight** | SQL-first `.sq` files → typesafe Kotlin; KMP-ready for future iOS |
|
||||||
|
| DI | **Hilt** | Compile-time safe, Android standard |
|
||||||
|
| Preferences | **DataStore** | Non-sensitive prefs (theme, selected site) |
|
||||||
|
| Secrets | **EncryptedSharedPreferences** | API keys via MasterKey + AES-256-GCM (AndroidX Security Crypto / Tink) |
|
||||||
|
| Charts | **Vico 1.x** | Compose-native; uses `CartesianChartHost` + `CartesianChartModelProducer` API |
|
||||||
|
|
||||||
|
### Package structure (`no.naiv.implausibly/`)
|
||||||
|
- `data/remote/` — `PlausibleApi` (single `POST /api/v2/query` endpoint), DTOs, auth interceptor
|
||||||
|
- `data/local/` — SQLDelight `.sq` schemas (`CachedStats`, `Instance` tables)
|
||||||
|
- `data/repository/` — `StatsRepository`, `SiteRepository`, `InstanceRepository`, `CacheManager`
|
||||||
|
- `data/` — `ApiKeyStore` (encrypted), `AppPreferences` (DataStore)
|
||||||
|
- `domain/model/` — Domain models decoupled from API DTOs (`AccessMode`, `PlausibleInstance`, `Site`, `DateRange`, `DashboardData`)
|
||||||
|
- `di/` — Hilt modules (`DatabaseModule`, `NetworkModule`)
|
||||||
|
- `ui/setup/` — Onboarding + instance management (add/edit/delete/switch)
|
||||||
|
- `ui/sites/` — Site list / picker
|
||||||
|
- `ui/dashboard/` — Main stats dashboard, components (`StatCard`, `VisitorChart`), sections
|
||||||
|
- `ui/filter/` — Filter bottom sheet
|
||||||
|
- `ui/theme/` — Material 3 theme, typography, colors
|
||||||
|
- `ui/navigation/` — NavHost, routes
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
Everything goes through **one endpoint**: `POST /api/v2/query` on the configured Plausible instance.
|
||||||
|
|
||||||
|
- Dashboard loads fire ~8 concurrent queries via `coroutineScope { async }` — one failure cancels all
|
||||||
|
- Rate limit: 600 req/hr (generous for dashboard use)
|
||||||
|
- Realtime: same endpoint with `"date_range": "realtime"`, polled every 30s in foreground
|
||||||
|
- Three access modes: full API key, stats-only key, public shared link (no key)
|
||||||
|
- API docs: https://plausible.io/docs/stats-api/query
|
||||||
|
|
||||||
|
## Caching (SQLDelight)
|
||||||
|
|
||||||
|
Cache key = hash of `(instanceId, siteId, queryBody)`. TTL varies by date range:
|
||||||
|
- `realtime` → never cached
|
||||||
|
- `today` → 5 min
|
||||||
|
- `7d`/`30d` → 30 min
|
||||||
|
- `6mo`/`12mo` → 2 hours
|
||||||
|
- Custom past range → 24 hours
|
||||||
|
|
||||||
|
Show cached data immediately on app open, refresh in background.
|
||||||
|
|
||||||
|
## Critical Constraints
|
||||||
|
|
||||||
|
- **Zero Play Services dependencies** — audit `./gradlew :app:dependencies` before every release. AndroidX Security Crypto uses Tink (Apache 2.0, pure Java), not Play.
|
||||||
|
- **All dependencies must be GPL-3.0-compatible** (Apache 2.0, MIT, LGPL OK; no AGPL, no proprietary)
|
||||||
|
- **F-Droid reproducible builds** — pin all dependency versions in `gradle/libs.versions.toml`, use locked Gradle wrapper
|
||||||
|
- **No telemetry** — zero analytics, nothing phones home
|
||||||
|
- **SPDX header** `GPL-3.0-only` in every source file
|
||||||
|
- **HTTPS enforced** for all network calls; no default certificate pinning (self-hosted users may use custom CAs)
|
||||||
|
|
||||||
|
## Gradle Configuration
|
||||||
|
|
||||||
|
- Version catalog: `gradle/libs.versions.toml` (all versions pinned)
|
||||||
|
- Convention plugins in `build-logic/` (`android.convention`, `compose.convention`)
|
||||||
|
- Gradle 8.10.2, Kotlin 2.1.0, AGP 8.7.3
|
||||||
|
|
||||||
|
## Repository Hosting
|
||||||
|
|
||||||
|
This project is hosted on Forgejo at `kode.naiv.no`. Use `fj` (Forgejo CLI) for PRs, issues, releases — not `gh`.
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
build.gradle.kts
Normal file
10
build.gradle.kts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.hilt) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.sqldelight) apply false
|
||||||
|
}
|
||||||
9
gradle.properties
Normal file
9
gradle.properties
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
# Project-wide Gradle settings.
|
||||||
|
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
89
gradle/libs.versions.toml
Normal file
89
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
[versions]
|
||||||
|
agp = "8.7.3"
|
||||||
|
kotlin = "2.1.0"
|
||||||
|
ksp = "2.1.0-1.0.29"
|
||||||
|
|
||||||
|
# AndroidX
|
||||||
|
core-ktx = "1.15.0"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
activity-compose = "1.9.3"
|
||||||
|
navigation-compose = "2.8.5"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
security-crypto = "1.1.0-alpha06"
|
||||||
|
|
||||||
|
# Compose
|
||||||
|
compose-bom = "2024.12.01"
|
||||||
|
|
||||||
|
# Hilt
|
||||||
|
hilt = "2.53.1"
|
||||||
|
hilt-navigation-compose = "1.2.0"
|
||||||
|
|
||||||
|
# Ktor
|
||||||
|
ktor = "3.0.3"
|
||||||
|
|
||||||
|
# SQLDelight
|
||||||
|
sqldelight = "2.0.2"
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
junit = "4.13.2"
|
||||||
|
mockk = "1.13.13"
|
||||||
|
turbine = "1.2.0"
|
||||||
|
kotlinx-coroutines-test = "1.9.0"
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
kotlinx-serialization = "1.7.3"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
# AndroidX
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
|
||||||
|
|
||||||
|
# Compose BOM
|
||||||
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
# Hilt
|
||||||
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||||
|
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
|
||||||
|
|
||||||
|
# Ktor
|
||||||
|
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
|
||||||
|
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
|
||||||
|
|
||||||
|
# SQLDelight
|
||||||
|
sqldelight-android-driver = { group = "app.cash.sqldelight", name = "android-driver", version.ref = "sqldelight" }
|
||||||
|
sqldelight-coroutines = { group = "app.cash.sqldelight", name = "coroutines-extensions", version.ref = "sqldelight" }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||||
|
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
176
gradlew
vendored
Executable file
176
gradlew
vendored
Executable file
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS=""
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
|
if $cygwin ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
(0) set -- ;;
|
||||||
|
(1) set -- "$args0" ;;
|
||||||
|
(2) set -- "$args0" "$args1" ;;
|
||||||
|
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=$(save "$@")
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
|
||||||
|
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||||
|
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
84
gradlew.bat
vendored
Normal file
84
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:init
|
||||||
|
@rem Get command-line arguments, handling Windows variants
|
||||||
|
|
||||||
|
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||||
|
|
||||||
|
:win9xME_args
|
||||||
|
@rem Slurp the command line arguments.
|
||||||
|
set CMD_LINE_ARGS=
|
||||||
|
set _SKIP=2
|
||||||
|
|
||||||
|
:win9xME_args_slurp
|
||||||
|
if "x%~1" == "x" goto execute
|
||||||
|
|
||||||
|
set CMD_LINE_ARGS=%*
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
439
plan.md
Normal file
439
plan.md
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
# Plausible Dashboard — Android App Plan
|
||||||
|
|
||||||
|
**Name:** Implausibly
|
||||||
|
**Stack:** Kotlin · Jetpack Compose · Material 3
|
||||||
|
**License:** GPL-3.0-only
|
||||||
|
**Target:** API 26+ (Android 8.0, covers ~97% of devices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why This Exists
|
||||||
|
|
||||||
|
The existing third-party apps (Applausible, Plausible Analytics Mobile) are all closed-source.
|
||||||
|
If you're running self-hosted Plausible CE specifically *because* you care about control and
|
||||||
|
transparency, a closed-source dashboard app is an odd fit. This app is:
|
||||||
|
|
||||||
|
- **Fully open source** — audit it, fork it, contribute
|
||||||
|
- **Self-hosted first** — configurable base URL, no Plausible.io assumptions baked in
|
||||||
|
- **No account/subscription required in the app itself** — just your Plausible API key
|
||||||
|
|
||||||
|
The Plausible Stats API v2 (`POST /api/v2/query`) is well-designed and gives us everything
|
||||||
|
we need. Rate limit is 600 req/hr which is generous for a dashboard viewer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
### 2.1 High-Level Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ UI (Jetpack Compose) │
|
||||||
|
│ Material 3 · Dynamic Color │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ ViewModel (per screen) │
|
||||||
|
│ StateFlow · UiState sealed class │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Repository Layer │
|
||||||
|
│ Coordinates API + cache │
|
||||||
|
├──────────────┬──────────────────────┤
|
||||||
|
│ Remote │ Local Cache │
|
||||||
|
│ (Ktor) │ (SQLDelight) │
|
||||||
|
└──────────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Key Technology Choices (with rationale)
|
||||||
|
|
||||||
|
| Choice | Why |
|
||||||
|
|--------|-----|
|
||||||
|
| **Ktor** for HTTP | Pure Kotlin, no annotation processing, lighter than Retrofit for a simple POST-only API. We literally have one endpoint. |
|
||||||
|
| **kotlinx.serialization** | Compiler plugin, no reflection. Pairs naturally with Ktor. |
|
||||||
|
| **SQLDelight** for local cache | SQL-first: you write `.sq` files, it generates typesafe Kotlin. KMP-native, so the entire data layer can be shared with a future iOS app. Lighter than Room, no annotation processing. |
|
||||||
|
| **Hilt** for DI | Standard for Android, reduces boilerplate vs manual DI. Could argue for Koin but Hilt has better compile-time safety. |
|
||||||
|
| **DataStore** for preferences | Non-sensitive prefs: selected site, theme, UI state. Replaces SharedPreferences. |
|
||||||
|
| **EncryptedSharedPreferences** for secrets | API keys stored with MasterKey + AES-256-GCM. EncryptedDataStore never graduated to stable; ESP is solid and well-tested. |
|
||||||
|
| **Vico** for charts | Compose-native charting library, actively maintained. Alternative: compose-charts or rolling our own with Canvas. |
|
||||||
|
|
||||||
|
**F-Droid compliance:** Every dependency is Apache 2.0 or compatible. Zero Play Services
|
||||||
|
dependencies anywhere in the tree. AndroidX Security Crypto uses Tink (Apache 2.0, pure
|
||||||
|
Java crypto, no Play linkage). Nothing phones home.
|
||||||
|
|
||||||
|
### 2.3 Package Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
no.naiv.implausibly/
|
||||||
|
├── data/
|
||||||
|
│ ├── remote/
|
||||||
|
│ │ ├── PlausibleApi.kt # Ktor client, single /api/v2/query call
|
||||||
|
│ │ ├── dto/ # API request/response models
|
||||||
|
│ │ └── interceptor/ # Auth header injection (per-instance)
|
||||||
|
│ ├── local/
|
||||||
|
│ │ ├── ImplausiblyDatabase.sq # SQLDelight schema + queries
|
||||||
|
│ │ └── adapters/ # Column adapters (enums, etc.)
|
||||||
|
│ └── repository/
|
||||||
|
│ ├── StatsRepository.kt # Single source of truth pattern
|
||||||
|
│ ├── SiteRepository.kt
|
||||||
|
│ └── InstanceRepository.kt # CRUD for Plausible instances
|
||||||
|
├── domain/
|
||||||
|
│ ├── model/ # Domain models (decoupled from API DTOs)
|
||||||
|
│ └── usecase/ # Optional, only if logic warrants it
|
||||||
|
├── ui/
|
||||||
|
│ ├── theme/ # Material 3 theme, typography, colors
|
||||||
|
│ ├── navigation/ # NavHost, routes
|
||||||
|
│ ├── setup/ # Onboarding + instance management
|
||||||
|
│ │ └── instances/ # Add/edit/delete/switch instances
|
||||||
|
│ ├── sites/ # Site list / site picker
|
||||||
|
│ ├── dashboard/ # Main stats dashboard
|
||||||
|
│ │ ├── DashboardScreen.kt
|
||||||
|
│ │ ├── DashboardViewModel.kt
|
||||||
|
│ │ ├── components/ # StatCard, VisitorChart, etc.
|
||||||
|
│ │ └── sections/ # TopPages, Sources, Locations, Devices
|
||||||
|
│ ├── filter/ # Filter sheet (date range, dimensions)
|
||||||
|
│ └── common/ # Shared composables
|
||||||
|
└── di/ # Hilt modules
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API Integration
|
||||||
|
|
||||||
|
**API Reference:** The Plausible Stats API v2 documentation lives at
|
||||||
|
<https://plausible.io/docs/stats-api>. The query endpoint reference is at
|
||||||
|
<https://plausible.io/docs/stats-api/query>. For self-hosted instances the
|
||||||
|
base URL changes but the path and schema are identical.
|
||||||
|
|
||||||
|
### 3.1 The One Endpoint
|
||||||
|
|
||||||
|
Everything goes through `POST /api/v2/query`. The request body looks like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"site_id": "example.com",
|
||||||
|
"metrics": ["visitors", "pageviews", "bounce_rate", "visit_duration"],
|
||||||
|
"date_range": "7d",
|
||||||
|
"dimensions": ["visit:source"],
|
||||||
|
"filters": [["is_not", "visit:country_name", [""]]],
|
||||||
|
"order_by": [["visitors", "desc"]],
|
||||||
|
"limit": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Query Strategy
|
||||||
|
|
||||||
|
The dashboard needs multiple queries to populate all sections. We batch these with
|
||||||
|
`coroutineScope { }` to run them concurrently:
|
||||||
|
|
||||||
|
| Section | metrics | dimensions | date_range |
|
||||||
|
|---------|---------|------------|------------|
|
||||||
|
| Top stats row | visitors, pageviews, bounce_rate, visit_duration | (none) | user-selected |
|
||||||
|
| Visitor graph | visitors | time:day (or time:hour for "today") | user-selected |
|
||||||
|
| Top sources | visitors | visit:source | user-selected |
|
||||||
|
| Top pages | visitors | event:page | user-selected |
|
||||||
|
| Countries | visitors | visit:country_name | user-selected |
|
||||||
|
| Devices | visitors | visit:device | user-selected |
|
||||||
|
| Browsers | visitors | visit:browser | user-selected |
|
||||||
|
| OS | visitors | visit:os | user-selected |
|
||||||
|
|
||||||
|
That's ~8 concurrent requests per dashboard load. At 600/hr rate limit, a user can
|
||||||
|
refresh every ~50 seconds continuously without hitting it. Fine for a dashboard.
|
||||||
|
|
||||||
|
### 3.3 Realtime
|
||||||
|
|
||||||
|
Plausible's realtime visitor count is available via the same v2 endpoint with
|
||||||
|
`"date_range": "realtime"`. We poll this on a configurable interval (default: 30s)
|
||||||
|
when the app is in foreground. No WebSocket available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Screens & UX
|
||||||
|
|
||||||
|
### 4.1 Setup / Onboarding
|
||||||
|
|
||||||
|
Three ways to connect to a Plausible instance:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Add Instance │
|
||||||
|
│ │
|
||||||
|
│ ○ API Key (full access) │
|
||||||
|
│ ○ Stats API only │
|
||||||
|
│ ○ Public dashboard (shared │
|
||||||
|
│ link, no key needed) │
|
||||||
|
│ │
|
||||||
|
│ Instance URL │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ https://... │ │
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ API Key (if applicable) │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ ●●●●●●●●●●●●● │ │ ← password field, paste-friendly
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Friendly name (optional) │
|
||||||
|
│ ┌────────────────────┐ │
|
||||||
|
│ │ │ │ ← auto-filled on successful test
|
||||||
|
│ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [ Test Connection ] │ ← minimal query to validate
|
||||||
|
│ [ Save & Continue ] │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
- **Multiple instances from day one** — stored as a list, each with its own
|
||||||
|
base URL, credentials, and friendly name. Switching is a top-level action.
|
||||||
|
- **Friendly name:** editable at all times. If left blank when "Test Connection"
|
||||||
|
succeeds, auto-populate from the response (e.g. the first site_id, or the
|
||||||
|
instance hostname as fallback). User can always override before or after saving.
|
||||||
|
Editable later from instance settings.
|
||||||
|
- API keys in EncryptedSharedPreferences (MasterKey + AES-256-GCM)
|
||||||
|
- Non-sensitive prefs (selected instance, theme) in regular DataStore
|
||||||
|
- Test connection fires a minimal `/api/v2/query` (visitors, 1d) to validate
|
||||||
|
- Public shared links just need the URL — no key at all
|
||||||
|
|
||||||
|
### 4.2 Site Picker
|
||||||
|
|
||||||
|
Simple list of sites the API key has access to. Pull from the Sites API if available
|
||||||
|
(requires Sites API key), otherwise let users manually add site_ids. Show realtime
|
||||||
|
visitor count next to each site as a live badge.
|
||||||
|
|
||||||
|
### 4.3 Main Dashboard
|
||||||
|
|
||||||
|
Maps closely to the Plausible web dashboard — users already know this layout.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ example.com ⚡ 3 live │
|
||||||
|
│ ┌──────┬──────┬──────┬──────┐ │
|
||||||
|
│ │ 1.2k │ 3k │ 45% │ 52s │ │
|
||||||
|
│ │visit.│views │bounce│ dur. │ │
|
||||||
|
│ └──────┴──────┴──────┴──────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Today] [7d] [30d] [custom] │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────┐ │
|
||||||
|
│ │ 📈 visitor graph │ │
|
||||||
|
│ │ (Vico line/bar chart) │ │
|
||||||
|
│ └──────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ▸ Top Sources │
|
||||||
|
│ Google ████████░░ 842 │
|
||||||
|
│ Direct █████░░░░░ 512 │
|
||||||
|
│ twitter.com ██░░░░░░░░ 201 │
|
||||||
|
│ │
|
||||||
|
│ ▸ Top Pages │
|
||||||
|
│ / ████████░░ 1.1k │
|
||||||
|
│ /blog/post-1 ████░░░░░░ 423 │
|
||||||
|
│ │
|
||||||
|
│ ▸ Countries · Devices · Browsers │
|
||||||
|
│ (expandable sections) │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Filter System
|
||||||
|
|
||||||
|
Bottom sheet with combinable filters matching the API's filter syntax:
|
||||||
|
- Country, source, page, device, browser, OS
|
||||||
|
- `is`, `is_not`, `contains`, `matches` operators
|
||||||
|
- Multiple filters compose with AND (matching API behavior)
|
||||||
|
|
||||||
|
This is where we can genuinely beat Applausible — deep filtering is powerful
|
||||||
|
and the v2 API supports it well.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Offline & Caching Strategy
|
||||||
|
|
||||||
|
### 5.1 Cache Model
|
||||||
|
|
||||||
|
SQLDelight stores the last successful response for each query signature.
|
||||||
|
A query signature is the hash of `(instanceId, site_id, query_body)`.
|
||||||
|
|
||||||
|
The schema lives in `.sq` files — SQL-first, generates typesafe Kotlin.
|
||||||
|
This is KMP-native, so the entire data layer can be shared with iOS later.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- src/commonMain/sqldelight/no/naiv/implausibly/CachedStats.sq
|
||||||
|
|
||||||
|
CREATE TABLE cached_stats (
|
||||||
|
query_hash TEXT NOT NULL PRIMARY KEY, -- hash of (instanceId, siteId, queryBody)
|
||||||
|
instance_id TEXT NOT NULL,
|
||||||
|
site_id TEXT NOT NULL,
|
||||||
|
response_json TEXT NOT NULL,
|
||||||
|
fetched_at INTEGER NOT NULL, -- epoch millis
|
||||||
|
date_range TEXT NOT NULL -- for TTL decisions
|
||||||
|
);
|
||||||
|
|
||||||
|
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 < ?;
|
||||||
|
|
||||||
|
-- src/commonMain/sqldelight/no/naiv/implausibly/Instance.sq
|
||||||
|
|
||||||
|
CREATE TABLE instances (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY, -- UUID
|
||||||
|
name TEXT NOT NULL, -- friendly name
|
||||||
|
base_url TEXT NOT NULL, -- e.g. https://plausible.example.com
|
||||||
|
access_mode TEXT NOT NULL, -- FULL_API | STATS_ONLY | PUBLIC_LINK
|
||||||
|
api_key_ref TEXT, -- key into EncryptedSharedPreferences (null for public)
|
||||||
|
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 = ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Domain models remain decoupled from the generated DB types:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
enum class AccessMode { FULL_API, STATS_ONLY, PUBLIC_LINK }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 TTL Strategy
|
||||||
|
|
||||||
|
| date_range | Cache TTL | Rationale |
|
||||||
|
|------------|-----------|-----------|
|
||||||
|
| realtime | 0 (never cache) | Stale realtime is worse than no data |
|
||||||
|
| today | 5 min | Active day, changes frequently |
|
||||||
|
| 7d, 30d | 30 min | Reasonably stable |
|
||||||
|
| 6mo, 12mo | 2 hours | Historical, barely changes |
|
||||||
|
| custom (past) | 24 hours | Completed periods don't change |
|
||||||
|
|
||||||
|
On app open: show cached data immediately, then refresh in background.
|
||||||
|
On network error: show cached data with a subtle "last updated X ago" indicator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Home Screen Widgets (Future / Maybe)
|
||||||
|
|
||||||
|
Deferred. If added later, Glance (Compose-based widget framework) is the right
|
||||||
|
choice. WorkManager for periodic updates (minimum 15 min on Android). No Play
|
||||||
|
Services needed — WorkManager is pure AndroidX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Security Considerations
|
||||||
|
|
||||||
|
- **API key storage:** EncryptedSharedPreferences (MasterKey + AES-256-GCM).
|
||||||
|
Uses AndroidX Security Crypto → Tink (Apache 2.0, pure Java, no Play Services).
|
||||||
|
- **Network:** HTTPS enforced. Certificate pinning optional (self-hosted users
|
||||||
|
might use custom CAs, so we can't pin by default — offer it as a setting).
|
||||||
|
- **No analytics in the analytics app.** Zero telemetry.
|
||||||
|
- **No Google Play dependencies.** Not as a library, not as a transitive dep,
|
||||||
|
not anywhere. Audit `./gradlew :app:dependencies` before every release.
|
||||||
|
- **No third-party dependencies that phone home.** Audit all transitive deps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Build & Release
|
||||||
|
|
||||||
|
### 8.1 Build System
|
||||||
|
- Gradle with version catalogs (libs.versions.toml)
|
||||||
|
- Convention plugins for shared config
|
||||||
|
- Forgejo Actions CI: lint, unit test, instrumented test, APK build
|
||||||
|
|
||||||
|
### 8.2 Distribution
|
||||||
|
- **F-Droid** (primary) — fits the open source + privacy ethos perfectly
|
||||||
|
- **Forgejo Releases** (kode.naiv.no) — signed APKs for direct install
|
||||||
|
|
||||||
|
No Google Play. Play App Signing requires handing Google a signing key, and
|
||||||
|
there's no reason to pull in any Play components. The target audience
|
||||||
|
(self-hosted Plausible users who care about privacy) overwhelmingly overlaps
|
||||||
|
with F-Droid users. Anyone else can sideload the APK.
|
||||||
|
|
||||||
|
### 8.3 Reproducible Builds
|
||||||
|
Target F-Droid reproducible build verification from day one.
|
||||||
|
Pin all dependency versions, use a locked Gradle wrapper.
|
||||||
|
|
||||||
|
### 8.4 License — GPL-3.0-only
|
||||||
|
|
||||||
|
The app is released under the **GNU General Public License v3.0 only** (SPDX: `GPL-3.0-only`).
|
||||||
|
|
||||||
|
Key implications:
|
||||||
|
- **Copyleft**: Any distributed fork or derivative work must also be released under GPL-3.0.
|
||||||
|
This ensures the open-source intent cannot be stripped by downstream distributors.
|
||||||
|
- **Source availability**: Anyone who distributes a compiled APK must make the corresponding
|
||||||
|
source code available under the same terms (or provide a written offer to do so).
|
||||||
|
- **F-Droid compatibility**: GPL-3.0 is fully accepted by F-Droid. All bundled dependencies
|
||||||
|
must be Apache 2.0, MIT, LGPL, or otherwise GPL-compatible (no AGPL, no proprietary).
|
||||||
|
- **No CLA required**: Contributions are accepted under GPL-3.0 — contributors retain their
|
||||||
|
copyright. A `CONTRIBUTORS` file will track significant contributors.
|
||||||
|
- **Binary distribution**: Signed APKs on Forgejo Releases are GPL-3.0 binaries. The signing
|
||||||
|
key is never required to be published; only the source code is.
|
||||||
|
|
||||||
|
Files to include in the repository:
|
||||||
|
- `LICENSE` — full GPL-3.0 license text
|
||||||
|
- `SPDX-License-Identifier: GPL-3.0-only` header in every source file (or `.reuse/` tree)
|
||||||
|
- `NOTICE` — attributions for Apache-2.0 / LGPL transitive dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Development Phases
|
||||||
|
|
||||||
|
### Phase 1: Core (MVP)
|
||||||
|
- [ ] Project setup (Gradle, Hilt, Navigation)
|
||||||
|
- [ ] Instance management (add/edit/delete, multi-instance)
|
||||||
|
- [ ] Setup screen with three access modes (full API, stats-only, public shared link)
|
||||||
|
- [ ] Site list screen
|
||||||
|
- [ ] Basic dashboard (top stats + visitor graph + top sources/pages)
|
||||||
|
- [ ] Date range selector (today, 7d, 30d, 12mo)
|
||||||
|
- [ ] Pull-to-refresh
|
||||||
|
- [ ] Basic SQLDelight caching
|
||||||
|
|
||||||
|
**Ship this.** It's already useful.
|
||||||
|
|
||||||
|
### Phase 2: Polish
|
||||||
|
- [ ] All dashboard sections (countries, devices, browsers, OS)
|
||||||
|
- [ ] Filter system (bottom sheet, combinable filters)
|
||||||
|
- [ ] Material You dynamic color
|
||||||
|
- [ ] Landscape layout (two-column dashboard)
|
||||||
|
- [ ] Error handling & offline indicator
|
||||||
|
- [ ] F-Droid submission + reproducible builds
|
||||||
|
|
||||||
|
### Phase 3: Nice to Have
|
||||||
|
- [ ] Comparison mode (this period vs previous)
|
||||||
|
- [ ] Traffic spike alerts (WorkManager polling, local notification)
|
||||||
|
- [ ] Goal conversions section
|
||||||
|
- [ ] Custom date ranges
|
||||||
|
- [ ] Segments support (API v2 supports them)
|
||||||
|
- [ ] Tablet layout
|
||||||
|
- [ ] Home screen widgets (Glance) — if there's demand
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open Questions
|
||||||
|
|
||||||
|
1. **Kotlin Multiplatform extraction:** With SQLDelight + Ktor + kotlinx.serialization,
|
||||||
|
the data layer is already KMP-ready. When the time comes, extract `data/` and
|
||||||
|
`domain/` into a shared KMP module, add an iOS app with SwiftUI consuming it,
|
||||||
|
and implement `expect`/`actual` for platform secrets (Keychain vs ESP).
|
||||||
|
The only Android-specific pieces are Hilt, DataStore, and EncryptedSharedPreferences.
|
||||||
|
|
||||||
|
2. **Sites API auto-discovery:** The Stats API key and Sites API key are separate
|
||||||
|
permissions. For users with only a Stats API key, we fall back to manual
|
||||||
|
site_id entry. Should we try the Sites API optimistically and fall back,
|
||||||
|
or ask the user upfront what kind of key they have?
|
||||||
|
|
||||||
|
3. **Shared link auth:** Plausible shared links can optionally be
|
||||||
|
password-protected. Need to verify how auth works for those — likely
|
||||||
|
a query parameter or header.
|
||||||
20
settings.gradle.kts
Normal file
20
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Implausibly"
|
||||||
|
include(":app")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue