commit 96dd878ac5f8908cce2980d4f1fdea5428185503 Author: null Date: Tue Aug 18 02:16:47 2026 -0500 chore: adopt the project template and add the Kotlin/Compose skeleton Period was a bare directory holding one 2,527-line specification, with no git repository, no tracker and no documentation convention. This is the adoption from Projects/Template/START-HERE-New-Project.md, plus a project that compiles so the hooks and future guards have something real to run against. Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the capitalised Docs/ is gone. Every scaffolded document was filled in for Period. docs/OPERATIONS.md deleted — an offline app is not a deployed service. DOC_TRUST_MAP.md written last, describing what is actually here, including what this project deliberately does not have. Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm") and cannot see the Android SDK, so the engine is testable without an emulator — 17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51. BaselinePredictionEngine is a robust-median prototype and explicitly not the product; it exists so Batch 02's replacement can be shown to be better rather than merely different. Versions verified against their official sources today rather than inherited from the specification's own numbers, which that document asks for: Kotlin 2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt 2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk stays 36, Play's floor from 2026-08-31, and the difference is deliberate. Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md. Three hooks in .githooks/, with pre-commit adapted to Gradle. closes #1 closes #2 diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..d02ebfc --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Every commit says what kind of change it is, before it says anything else. +# +# ## Why +# +# Ninety-nine of this repository's commits already carry a conventional type — +# `feat:`, `fix:`, `docs:`, `chore:` — and ninety-six do not, including a run of +# recent ones written as bare sentences. That split is the problem: `git log +# --grep '^fix'` answers "what have we fixed" for half the history and quietly +# omits the other half, which is worse than having no convention at all, because +# the answer looks complete. +# +# So the type is required, and the vocabulary is closed. A closed list is the +# point — `feat`, `feature` and `feat!` as three spellings of one idea is how a +# convention stops being searchable. +# +# ## The vocabulary +# +# feat a new capability somebody can use +# fix a defect. The thing behaved wrongly and now does not +# ui appearance, layout, copy, or interaction, with no change in what +# the software knows or decides +# docs documentation only +# test tests only, with no change to what they test +# refactor same behaviour, different shape. If behaviour changed it is not this +# security hardening, boundaries, secret handling. Kept separate from `fix` +# on purpose: "what have we hardened" is a question worth being able +# to ask on its own, and it is the one an auditor asks first +# perf faster or lighter, same answers +# chore tooling, dependencies, releases. The bucket for work that is not +# about the product +# +# `harden`, `style` and `content` each appear once or twice in the history and +# are deliberately not here — they are `security`, `ui` and `docs` under other +# names, and a synonym is a hole in a closed list. +# +# ## Scope is optional, and lowercase +# +# fix(admin): ... the fifteen existing `admin` scopes, and `release`, +# `integrations`, `db` and the rest, all keep working. +# +# ## What it deliberately does not enforce +# +# Subject length. Thirty-five existing subjects run past 72 characters, several +# of them deliberately, and rejecting a commit for a well-written 80-character +# sentence would teach people to use `--no-verify` — which switches off the +# checks that actually matter. The type is the part a tool reads; the length is +# a matter of taste and stays that way. +# +# ## Escape hatch +# +# SKIP_GUARDS=1 git commit ... skips this too, and says so +# +# The same variable the pre-commit hook uses, because two switches for "I know +# what I am doing" is one more than anybody will remember. + +set -uo pipefail + +say() { printf '\033[1mcommit-msg:\033[0m %s\n' "$*" >&2; } + +message_file="$1" +subject=$(head -1 "$message_file") + +if [ -n "${SKIP_GUARDS:-}" ]; then + say "SKIP_GUARDS set — the commit type was NOT checked." + exit 0 +fi + +# Git writes these itself, or writes them on a human's behalf during a rebase. +# Rejecting them would break `git merge`, `git revert` and autosquash for a +# convention none of them ever agreed to. +case "$subject" in + "Merge "*|"Revert "*|"fixup!"*|"squash!"*|"amend!"*) + exit 0 + ;; +esac + +# A comment-only file is an aborted commit; git handles that itself. +if [ -z "${subject// /}" ]; then + exit 0 +fi + +TYPES="feat|fix|ui|docs|test|refactor|security|perf|chore" + +if printf '%s' "$subject" | grep -qE "^(${TYPES})(\([a-z0-9._-]+\))?!?: .+"; then + exit 0 +fi + +say "the subject line needs a type." +say "" +say " got: ${subject}" +say "" +say " expected: : e.g. fix: stop counting milestones as records" +say " (): ui(admin): mark cloud models on the picker" +say "" +say " types: feat a new capability" +say " fix a defect, now not" +say " ui appearance, layout, copy — no change to what it decides" +say " docs documentation only" +say " test tests only" +say " refactor same behaviour, different shape" +say " security hardening, boundaries, secrets" +say " perf faster or lighter, same answers" +say " chore tooling, dependencies, releases" +say "" +say " Your message is kept. Run 'git commit' again to edit it, or" +say " SKIP_GUARDS=1 git commit ... to bypass this loudly." + +exit 1 diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100755 index 0000000..a7bbaa0 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# Every commit goes to Forgejo, without anybody having to remember the push. +# +# ## Why a hook rather than a habit +# +# The commit that added `pre-commit` sat unpushed for a day. Nothing was wrong +# with it; it just never got the second command. That is the whole failure this +# closes — work that exists on one laptop and nowhere else is work that is one +# disk away from gone, and it is invisible to anybody reading the tracker. +# +# ## It runs after the commit, and cannot undo one +# +# git ignores this hook's exit code, which is the right shape for the job: a +# network that is down must not cost somebody a commit they already made. So a +# failed push is **reported loudly and left in place** — the commit stands, the +# branch is simply still ahead, and the next commit tries again. +# +# What it will never do is force. A rejected push means the remote has something +# this checkout has not seen, and the fix for that is a human running a pull, not +# a hook overwriting the difference. +# +# ## When it deliberately stays out of the way +# +# - mid-rebase, mid-cherry-pick, mid-am: every step of a rebase fires this +# hook, and pushing an intermediate commit publishes a history that is about +# to be rewritten. Wait for the rebase to finish. +# - detached HEAD: there is no branch to push, and guessing one is worse than +# doing nothing. +# - no `origin`: a clone with no remote is a legitimate state, not an error. +# +# SKIP_PUSH=1 git commit ... commits without publishing, loudly +# +# The counterpart to `SKIP_GUARDS` in the pre-commit hook, and loud for the same +# reason: an exception that leaves no trace becomes a habit. + +set -uo pipefail + +cd "$(git rev-parse --show-toplevel)" || exit 0 + +say() { printf '\033[1mpost-commit:\033[0m %s\n' "$*" >&2; } + +if [ -n "${SKIP_PUSH:-}" ]; then + say "SKIP_PUSH set — this commit was NOT pushed." + exit 0 +fi + +git_dir=$(git rev-parse --git-dir) + +# A rebase, cherry-pick or `git am` fires this hook once per replayed commit. +# Those commits are provisional by definition. +for marker in rebase-merge rebase-apply CHERRY_PICK_HEAD; do + if [ -e "$git_dir/$marker" ]; then + say "a rebase or cherry-pick is in progress — not pushing until it finishes." + exit 0 + fi +done + +branch=$(git symbolic-ref --quiet --short HEAD) || { + say "detached HEAD — no branch to push." + exit 0 +} + +if ! git remote get-url origin >/dev/null 2>&1; then + say "no 'origin' remote — nothing to push to." + exit 0 +fi + +say "pushing $branch to origin…" + +# --porcelain keeps the output to one parseable line per ref rather than the +# usual banner, and the timeout is here because an unreachable SSH host +# otherwise hangs the terminal well past the point of being useful. +if timeout 60 git push --porcelain origin "$branch"; then + exit 0 +fi + +status=$? + +if [ "$status" -eq 124 ]; then + say "push timed out after 60s. The commit is safe locally; push when the" + say " remote is reachable." +else + say "push was refused. The commit is safe locally and the branch is now ahead." + say " If this is a non-fast-forward, pull and reconcile — this hook will" + say " not force, and should not." +fi + +# Deliberately zero. git ignores it either way, and returning non-zero here reads +# as though the commit failed when it did not. +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..3072fcc --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# The repo's own guards, before a commit rather than after it. +# +# ## Why this lives in the repository and not in .git/hooks +# +# `.git/hooks` is not versioned, so a hook living there protects exactly one +# checkout and silently protects nothing anywhere else. This directory is +# committed, and `core.hooksPath` points at it: +# +# git config core.hooksPath .githooks +# +# That one line is the only setup, and it is per clone. See the README. +# +# ## Adapted from the template's version +# +# The template's pre-commit runs `npx tsc --noEmit` and `npx vitest`. Period is +# a Kotlin/Android project with no npm anywhere, so the two guard commands are +# Gradle instead. Everything else — the ordering, the secret scan, the +# unstaged-changes warning, SKIP_GUARDS — is unchanged, because the arguments +# for those are not language-specific. +# +# ## What it checks, and why in this order +# +# 1. The secret scan, first and cheapest: it reads the staged diff only. A +# keystore caught here costs a `git reset`; the same keystore caught after a +# push cannot be undone at all — an upload key cannot be rotated once the app +# is published. +# 2. The JVM unit tests, when Kotlin is staged. They are the prediction +# acceptance cases from PRODUCT_PLAN.md §51 and they run on the JVM in about +# a second, which is the whole reason :domain:* is not an Android module. +# +# Compilation of the Android modules is deliberately NOT run here. It needs the +# SDK and takes half a minute, and a pre-commit hook people disable is worse +# than one that checks less. `./gradlew assembleRelease` belongs to the release +# checklist — docs/security/SECURITY_CHECKLIST.md — which is where it is. +# +# ## It warns about unstaged changes rather than failing on them +# +# Both commands run against the **working tree**, not the index. A clean run +# proves the working tree is good, which is only the same thing as the commit +# being good when nothing is left unstaged. Saying so is more honest than +# implying a guarantee that was not made. +# +# ## Escape hatch +# +# SKIP_GUARDS=1 git commit ... skips both, loudly +# git commit --no-verify ... skips the hook entirely, silently +# +# The first is preferred: it leaves a line in the terminal saying the guards did +# not run, which is the difference between a deliberate exception and a habit. +# +# ## Exit codes +# +# 0 the guards that applied ran and passed +# 1 a guard failed, or refused — the commit is stopped + +set -uo pipefail + +cd "$(git rev-parse --show-toplevel)" || exit 1 + +say() { printf '\033[1mpre-commit:\033[0m %s\n' "$*" >&2; } + +if [ -n "${SKIP_GUARDS:-}" ]; then + say "SKIP_GUARDS set — the secret scan and tests did NOT run for this commit." + exit 0 +fi + +# Nothing staged is not this hook's problem; git will refuse on its own. +if git diff --cached --quiet; then + exit 0 +fi + +# Credentials, before the commit exists. +if [ -f scripts/secrets.sh ]; then + if ! bash scripts/secrets.sh; then + say "possible credential in the staged changes — commit refused." + say "If it is real, ROTATE IT. Deleting the line does not remove it from a" + say "commit that already exists. If it is not, --allow the path or adjust" + say "the patterns; do not silence the check." + exit 1 + fi +else + # Said out loud rather than passed over: a missing scanner reads exactly like + # a scanner that found nothing. + say "note: scripts/secrets.sh is not here, so nothing scanned the staged diff." +fi + +# Only worth running when Kotlin or the build changed. A docs-only commit does +# not need the suite; a change to a build file does, because that is how a +# module boundary gets widened without anyone reading a Kotlin line. +staged=$(git diff --cached --name-only) +touches_code=$(printf '%s\n' "$staged" \ + | grep -cE '\.(kt|kts)$|^gradle/libs\.versions\.toml$|^gradle\.properties$' || true) + +if [ "$touches_code" -gt 0 ]; then + say "JVM unit tests…" + + # :domain:* only. These are pure-JVM modules, so this needs no Android SDK and + # no emulator, and it is the suite that guards the product's core claim. + if ! ./gradlew --quiet --console=plain :domain:cycle:test :domain:prediction:test; then + say "tests failed — commit refused." + exit 1 + fi +else + say "no .kt/.kts or build file staged — skipping the suite." +fi + +# Said last so it is the thing still on screen when the editor opens. +if ! git diff --quiet; then + say "NOTE: unstaged changes are present. The guards ran against the working" + say " tree, so they did not verify this commit in isolation." +fi + +exit 0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a04f5bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# --------------------------------------------------------------------------- +# Secrets. docs/security/SECURITY.md is the rule; this is the safety net, and +# it is not a substitute for reading that file. +# +# The upload keystore is the one secret here whose loss is permanent in BOTH +# directions: lost means this app can never be updated again, leaked means +# somebody else can sign as us. It cannot be rotated once the app is published. +# --------------------------------------------------------------------------- +local.properties +*.jks +*.keystore +keystore.properties +play-service-account*.json +.env +.env.* + +# Gradle and Android build output +.gradle/ +build/ +captures/ +.cxx/ +*.apk +*.aab +*.ap_ +*.dex +output.json + +# Python bytecode from the scripts in scripts/ +__pycache__/ +*.py[cod] + +# Editor and OS cruft +.DS_Store +.idea/ +*.iml +.vscode/ +*.swp +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f341dc --- /dev/null +++ b/README.md @@ -0,0 +1,169 @@ +# Period + +```text +Status: Draft +Owner: _null +Last reviewed: 2026-08-18 +Governs: README.md as the project-facing overview for Period +Review trigger: The first buildable feature release; any change to the stack, the + privacy promise, or how the project is built and run +``` + +A private Android period tracker that learns **your** cycle rather than the +average person's — and never sells your data. + +[What it is](#what-it-is) | [Status](#status) | [Build and run](#build-and-run) | +[Repository map](#repository-map) | [Project docs](#project-docs) | +[Agent notes](#agent-notes) + +_{ Android / Google Play | Kotlin + Jetpack Compose | Room, offline-first | +Personalized prediction with an honest window | Discreet notifications | +No account, no server, no data sale }_ + +The core loop is: + +```text +Track → Learn → Predict → Remind → Learn again +``` + +## What it is + +A focused tracker that answers one question well: **when is my next period +likely to start?** It records confirmed period dates, learns the individual's +pattern from them, and produces a most-likely date with a window and a +confidence label — never a bare date presented as fact. From that it estimates +ovulation and the fertile window, and it asks discreetly whether the period +started, using both *yes* and *not yet* to improve the next forecast. + +Two promises hold the product up: + +> **Your period. Better predicted.** — once there is enough personal history, +> the app must not fall back to a generic 28-day cycle. A user recording 34, 35, +> 36, 34, 35 who is predicted 28 is a core product defect, not a tuning issue. + +> **Your cycle belongs to you.** We will never sell period history, fertility +> information, ovulation estimates, cycle predictions or personally identifiable +> information — to advertisers, data brokers or anyone else. + +The second is enforced structurally, not remembered: health data cannot reach +the advertising subsystem, because the module that will hold ad code declares no +dependency on the cycle database or the prediction domain, and a guard proves it. + +What it deliberately is **not** — no community, no pregnancy mode, no chatbot, +no article feed, no symptom encyclopedia, and not contraception — is in +[docs/planning/PROJECT_PLAN.md](docs/planning/PROJECT_PLAN.md). + +## Status + +**Skeleton.** There is no usable app yet, and this section says so rather than +listing features that do not exist. + +| Surface | Status | Evidence | +| --- | --- | --- | +| Documentation tree and tracker convention | Adopted | this tree; milestones and issues in the tracker | +| Gradle / Kotlin / Compose project | Builds | `./gradlew assembleDebug` and `assembleRelease` both pass | +| Material 3 theme and tokens | Built | `core/designsystem` | +| Four-tab navigation shell | Built, placeholder screens | `app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt` | +| Cycle model and interval derivation | Built | `domain/cycle`, 5 tests | +| Prediction engine | **Baseline only** | `domain/prediction`, 12 acceptance tests from PRODUCT_PLAN §51 | +| Room persistence, DataStore | Not built | Batch 01 | +| Onboarding, Today, Calendar, Insights | Not built | Batch 03 | +| Fertility, notifications, privacy features, monetization | Not built | Batches 04–07 | +| QA | No round run | [docs/qa/ClaudeReport.md](docs/qa/ClaudeReport.md) | + +`BaselinePredictionEngine` is a robust-median prototype and is **not the +product**. PRODUCT_PLAN §11 names a plain average as an acceptable prototype and +an unacceptable final engine; Batch 02 replaces it with the recency-weighted, +trend-aware, "not yet"-conditioned engine §12 specifies. The acceptance tests +exist now so that replacement can be shown to be better rather than merely +different. + +## Build and run + +Prerequisites: a JDK 21 and the Android SDK with **platform 37** and +**build-tools 37.0.0** installed, `ANDROID_HOME` set. + +```bash +git config core.hooksPath .githooks # per clone, every clone — see below + +./gradlew test # JVM suites, no emulator, ~1s +./gradlew assembleDebug # app/build/outputs/apk/debug/ +./gradlew assembleRelease # minified, unsigned +``` + +`compileSdk` is 37 because the current AndroidX libraries require it; +`targetSdk` is 36, Google Play's floor for new apps from 2026-08-31. They are +deliberately different — raising `targetSdk` opts the app into runtime behaviour +changes and is a tested decision, not a build fix. + +**`git config core.hooksPath .githooks` is per clone.** Without it the hooks are +not installed and fail silently, which is the failure mode they exist to +prevent. `post-commit` pushes; see +[docs/architecture/githooks/README.md](docs/architecture/githooks/README.md). + +## Repository map + +```text +app/ application module, MainActivity, navigation shell +core/designsystem/ Material 3 theme and colour tokens +domain/cycle/ pure Kotlin — period records, cycle derivation +domain/prediction/ pure Kotlin — the forecast, window and confidence +scripts/ the six template scripts this project adopted +.githooks/ pre-commit, commit-msg, post-commit +docs/ product, architecture, design, QA, security, history +``` + +`domain/*` are `kotlin("jvm")` modules and cannot see the Android SDK. That is +on purpose: the prediction engine is the product, so it needs the most tests, +and tests that need an emulator are tests that do not get run. + +## Project docs + +Detailed procedures belong in docs; **open work belongs in the tracker**, not in +this README. + +| Doc | Purpose | +| --- | --- | +| [docs/DOC_TRUST_MAP.md](docs/DOC_TRUST_MAP.md) | Which document owns which answer, and which source wins when records disagree. **Read this first.** | +| [docs/planning/PRODUCT_PLAN.md](docs/planning/PRODUCT_PLAN.md) | The full V1 specification — prediction requirements, screens, copy, compliance | +| [docs/planning/PROJECT_PLAN.md](docs/planning/PROJECT_PLAN.md) | The short vision, the stack and its reasons, and what this is deliberately not | +| [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md) | What happens at the end of a piece of work | +| [docs/architecture/README.md](docs/architecture/README.md) | Modules, boundaries, data shapes, migrations | +| [docs/design/README.md](docs/design/README.md) | Tone, and the four rules that settle design arguments | +| [docs/security/SECURITY.md](docs/security/SECURITY.md) | Threat model, the advertising boundary, logging rules | +| [docs/TOOLS.md](docs/TOOLS.md) | Which script to run, and which ones can stop you | + +## Where the tracker is + +Milestones are batches, issues are deliverables, and severity labels are exactly +`P0`, `P1`, `P2` and `release-blocker`. Credentials come from the environment, +never from this repository: + +```bash +set -a; . ~/.openclaw/docker-registry.env; set +a +python3 scripts/forgejo-issue.py list +``` + +Two traps that cost an hour each otherwise: Cloudflare 1010-blocks clients that +do not look like a browser or curl, so every request needs +`User-Agent: curl/8.5.0` — `forgejo-issue.py` sends it and anything new must +too. And `/issues` returns pull requests unless `type=issues` is passed. + +## Agent notes + +- **Product truth comes from the code and the tracker before prose.** +- Do not keep a work list in this README or anywhere else in `docs/`. +- Finish with [docs/WORK_CYCLE.md](docs/WORK_CYCLE.md), every time: close what + you finished with the evidence, close the milestone if the batch landed, + update the documents the change triggered **in the same commit**, push, log + the entry, then reconcile and write the summary and next action. +- Nothing on privacyllc.dev writes itself except the tracker counts and the + pushed docs. +- **Do not claim a feature is built** unless you can cite the file, test or + screenshot that proves it. The status table above is the standard. +- **Verify current stable versions before changing the toolchain.** The ones in + `gradle/libs.versions.toml` were checked against the official sources on + 2026-08-18; PRODUCT_PLAN.md asks for that check rather than for its own + numbers to be trusted. +- Never put a cycle date, a prediction or a fertility state into a log, an + analytics event, or an ad request. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..3fb6a37 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "dev.privacyllc.period" + // compileSdk and targetSdk are deliberately different. compileSdk 37 is what + // the current AndroidX libraries require to compile against; targetSdk 36 is + // Google Play's floor for new apps from 2026-08-31 and is what the app opts + // into at runtime. Raising targetSdk opts in to behaviour changes and is a + // tested decision, not a build fix. + compileSdk = 37 + + defaultConfig { + applicationId = "dev.privacyllc.period" + minSdk = 26 + // Google Play requires API 36 for new apps and updates from 2026-08-31. + // Confirm the current requirement at submission time rather than here. + targetSdk = 36 + 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") + } + debug { + applicationIdSuffix = ".debug" + } + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +dependencies { + implementation(project(":core:designsystem")) + implementation(project(":domain:cycle")) + implementation(project(":domain:prediction")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.kotlinx.coroutines.core) + + 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) + + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..16f03e2 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Release builds are minified. Nothing project-specific is needed yet. +# +# When a rule IS added here it must say what breaks without it — a proguard file +# of unexplained -keep lines is a file nobody can ever safely shrink again. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..da963b3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt new file mode 100644 index 0000000..de46ea9 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/MainActivity.kt @@ -0,0 +1,22 @@ +package dev.privacyllc.period + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import dagger.hilt.android.AndroidEntryPoint +import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.navigation.PeriodApp + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + PeriodTheme { + PeriodApp() + } + } + } +} diff --git a/app/src/main/kotlin/dev/privacyllc/period/PeriodApplication.kt b/app/src/main/kotlin/dev/privacyllc/period/PeriodApplication.kt new file mode 100644 index 0000000..bc0c29b --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/PeriodApplication.kt @@ -0,0 +1,7 @@ +package dev.privacyllc.period + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class PeriodApplication : Application() diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt new file mode 100644 index 0000000..2126de0 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt @@ -0,0 +1,130 @@ +package dev.privacyllc.period.navigation + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Insights +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Today +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import dev.privacyllc.period.R +import dev.privacyllc.period.designsystem.PeriodTheme + +/** + * The four tabs from docs/planning/PRODUCT_PLAN.md §20. + * + * Four and only four. Every proposal to add a fifth is answered by + * docs/planning/PROJECT_PLAN.md's "deliberately not" list. + */ +enum class PeriodDestination( + val route: String, + @param:StringRes val labelRes: Int, + val icon: ImageVector, +) { + TODAY("today", R.string.tab_today, Icons.Filled.Today), + CALENDAR("calendar", R.string.tab_calendar, Icons.Filled.CalendarMonth), + INSIGHTS("insights", R.string.tab_insights, Icons.Filled.Insights), + SETTINGS("settings", R.string.tab_settings, Icons.Filled.Settings), +} + +@Composable +fun PeriodApp() { + val navController = rememberNavController() + val backStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = backStackEntry?.destination + + Scaffold( + bottomBar = { + NavigationBar { + PeriodDestination.entries.forEach { destination -> + val selected = currentDestination?.hierarchy?.any { it.route == destination.route } == true + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(destination.route) { + popUpTo(navController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(destination.icon, contentDescription = null) }, + label = { Text(stringResource(destination.labelRes)) }, + ) + } + } + }, + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = PeriodDestination.TODAY.route, + modifier = Modifier.padding(innerPadding), + ) { + PeriodDestination.entries.forEach { destination -> + composable(destination.route) { + PlaceholderScreen(stringResource(destination.labelRes)) + } + } + } + } +} + +/** + * Skeleton only. Each of these is replaced by its real screen in Batch 03, and + * saying "not built yet" on the screen is deliberate — a convincing mock is how + * a screen comes to be believed finished. + */ +@Composable +private fun PlaceholderScreen(title: String) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Filled.Circle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primaryContainer, + ) + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Not built yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PeriodAppPreview() { + PeriodTheme { PeriodApp() } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..528ac1e --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5c84730 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..80b1ebd --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #4A2545 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3c3341b --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ + + + Period + + Today + Calendar + Insights + Settings + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4c1c640 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +