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