commit 01d1f558b85f7e908648bfb21c1818568a718a1f Author: Jean-Luc Makiola Date: Wed Jun 17 19:40:53 2026 +0200 M0: Floret skeleton — Material 3 Expressive scaffold over OpenTasks (planned) Sibling to Calendula. Pure front-end posture; TaskContract data layer, task screens and reminder engine to follow (see docs/PLAN.md). Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..663245c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml,toml,json,md}] +indent_size = 2 + +[*.{kt,kts}] +ij_kotlin_packages_to_use_import_on_demand = unset + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9ac4914 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text=auto eol=lf +*.bat text eol=crlf +*.jar binary +*.png binary +*.jpg binary +*.gif binary +*.webp binary diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..e672869 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,93 @@ +name: CI + +on: + push: + branches: + - '**' + tags-ignore: + - '**' + +# Cancel superseded runs on the same branch. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + runs-on: docker + env: + ANDROID_HOME: /opt/android-sdk + ANDROID_SDK_ROOT: /opt/android-sdk + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + # Default ("tools platform-tools") drags in the Android Emulator + # (~300 MB) which the build never uses. + packages: '' + + - name: Setup Android SDK cache + uses: actions/cache@v4 + with: + path: /opt/android-sdk + key: ${{ runner.os }}-android-sdk-37-36.0.0 + + - name: Install Android SDK packages + run: | + yes | sdkmanager --licenses >/dev/null || true + sdkmanager \ + "platform-tools" \ + "platforms;android-37.0" \ + "build-tools;36.0.0" + + - name: Setup Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # No --no-daemon: the daemon lives only as long as this job container + # and lets the following steps skip JVM startup + reconfiguration. + - name: Lint (debug variant only) + run: ./gradlew lintDebug + + - name: Unit tests + run: ./gradlew testDebugUnitTest + + - name: Assemble debug APK + run: ./gradlew assembleDebug + + - name: Trivy filesystem scan + if: github.ref == 'refs/heads/main' + run: | + set -e + SUDO="" + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi + if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update + $SUDO apt-get install -y wget apt-transport-https gnupg lsb-release + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | $SUDO tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | $SUDO tee /etc/apt/sources.list.d/trivy.list + $SUDO apt-get update + $SUDO apt-get install -y trivy + fi + trivy filesystem --severity HIGH,CRITICAL --exit-code 0 . + continue-on-error: true diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..450b342 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,381 @@ +name: Release — F-Droid repo + Gitea release + +on: + push: + tags: + - '*' + workflow_dispatch: + +jobs: + ci: + runs-on: docker + env: + ANDROID_HOME: /opt/android-sdk + ANDROID_SDK_ROOT: /opt/android-sdk + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + packages: '' + + - name: Setup Android SDK cache + uses: actions/cache@v4 + with: + path: /opt/android-sdk + key: ${{ runner.os }}-android-sdk-37-36.0.0 + + - name: Install Android SDK packages + run: | + yes | sdkmanager --licenses >/dev/null || true + sdkmanager \ + "platform-tools" \ + "platforms;android-37.0" \ + "build-tools;36.0.0" + + - name: Setup Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + + # Lint already enforced on every push to main via ci.yaml. + # Release sanity only re-runs tests + a debug build to catch + # any tag-resolved drift (e.g. version code substitution issues). + + - name: Unit tests + run: ./gradlew testDebugUnitTest + + - name: Assemble debug APK (sanity) + run: ./gradlew assembleDebug + + build-and-deploy: + needs: ci + runs-on: docker + env: + ANDROID_HOME: /opt/android-sdk + ANDROID_SDK_ROOT: /opt/android-sdk + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + packages: '' + + - name: Setup Android SDK cache + uses: actions/cache@v4 + with: + path: /opt/android-sdk + key: ${{ runner.os }}-android-sdk-37-36.0.0 + + - name: Install Android SDK packages + run: | + yes | sdkmanager --licenses >/dev/null || true + sdkmanager \ + "platform-tools" \ + "platforms;android-37.0" \ + "build-tools;36.0.0" + + - name: Setup Gradle cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Install jq + run: | + set -e + SUDO="" + if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi + if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update + $SUDO apt-get install -y jq + elif command -v apk >/dev/null 2>&1; then + $SUDO apk add --no-cache jq + fi + + # Tag-only build steps. On a manual workflow_dispatch (ref = a branch, + # not a tag) these are skipped: the job then just re-signs the existing + # index with the configured repo key and re-uploads — used for key + # rotation / repo recovery without publishing a new APK. + - name: Set version from git tag + if: startsWith(github.ref, 'refs/tags/') + run: | + set -e + RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" + VERSION="${RAW_TAG#v}" + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + PATCH=$(echo "$VERSION" | cut -d. -f3) + MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0} + VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH )) + echo "Version: $VERSION, VersionCode: $VERSION_CODE" + sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts + sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts + grep -E 'versionName|versionCode' app/build.gradle.kts + # Export for later steps (F-Droid changelog, mapping asset name). + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV" + + - name: Setup Android Keystore + if: startsWith(github.ref, 'refs/tags/') + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + run: | + mkdir -p app + echo "$KEYSTORE_BASE64" | base64 --decode > app/upload-keystore.jks + cat > key.properties </dev/null 2>&1; then SUDO="sudo"; fi + $SUDO apt-get update + $SUDO apt-get install -y sshpass python3-pip + pip3 install --break-system-packages --upgrade fdroidserver + + - name: Fetch existing F-Droid repo from Hetzner + env: + HOST: ${{ secrets.HETZNER_HOST }} + USER: ${{ secrets.HETZNER_USER }} + PASS: ${{ secrets.HETZNER_PASS }} + run: | + set -euo pipefail + SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20" + mkdir -p fdroid + # Pull only the published repo/ (all apps' APKs), any per-app + # metadata, and the repo icon — enough to rebuild the index without + # dropping the other apps. The signing key is deliberately NOT pulled + # from the box; it comes from CI secrets in the next step so it never + # has to live in the web-served tree. + sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/repo" fdroid/ 2>/dev/null || true + sshpass -p "$PASS" scp $SSH_OPTS -r "$USER@$HOST:dev/fdroid/metadata" fdroid/ 2>/dev/null || true + sshpass -p "$PASS" scp $SSH_OPTS "$USER@$HOST:dev/fdroid/icon.png" fdroid/ 2>/dev/null || true + mkdir -p fdroid/repo fdroid/metadata + + - name: Restore F-Droid signing key and config from secrets + env: + FDROID_KEYSTORE_BASE64: ${{ secrets.FDROID_KEYSTORE_BASE64 }} + FDROID_CONFIG_BASE64: ${{ secrets.FDROID_CONFIG_BASE64 }} + run: | + set -euo pipefail + # Fail loudly if the repo key is not configured. NEVER auto-generate + # one: a fresh key changes the repo fingerprint and breaks every + # user's pinned repo. (Replaces the old `fdroid update --create-key` + # path, which silently rotated the key on a wiped server.) + if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then + echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2 + echo "Refusing to continue — will not auto-generate a new repo key." >&2 + exit 1 + fi + echo "$FDROID_KEYSTORE_BASE64" | base64 --decode > fdroid/keystore.p12 + echo "$FDROID_CONFIG_BASE64" | base64 --decode > fdroid/config.yml + test -s fdroid/keystore.p12 + test -s fdroid/config.yml + mkdir -p fdroid/repo/icons + + - name: Copy new APK to repo + if: startsWith(github.ref, 'refs/tags/') + run: | + set -e + mkdir -p fdroid/repo + REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" + SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')" + if [ -z "$SAFE_REF_NAME" ]; then + SAFE_REF_NAME="${GITHUB_SHA:-manual}" + fi + cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/floret_${SAFE_REF_NAME}.apk" + + - name: Copy metadata to F-Droid repo + run: | + mkdir -p fdroid/metadata + cp -r fdroid-metadata/* fdroid/metadata/ + + # Per-version "What's New" for F-Droid clients: the tag's CHANGELOG + # section written to changelogs/.txt (same extraction as the + # Gitea release notes). en-US only — F-Droid falls back to it for locales + # without their own changelog. fdroid update bakes this into the index. + - name: Generate F-Droid changelog for this version + if: startsWith(github.ref, 'refs/tags/') + run: | + set -e + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { flag = 1; next } + /^## \[/ { flag = 0 } + flag' CHANGELOG.md > /tmp/changelog.txt + sed -i -e '/./,$!d' /tmp/changelog.txt + if [ ! -s /tmp/changelog.txt ]; then + echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt + fi + CL_DIR="fdroid/metadata/de.jeanlucmakiola.floret/en-US/changelogs" + mkdir -p "$CL_DIR" + cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt" + echo "Wrote $CL_DIR/${VERSION_CODE}.txt" + + - name: Generate F-Droid Index + run: | + cd fdroid + fdroid update -c + + - name: Upload repo/ to Hetzner + env: + HOST: ${{ secrets.HETZNER_HOST }} + USER: ${{ secrets.HETZNER_USER }} + PASS: ${{ secrets.HETZNER_PASS }} + run: | + set -euo pipefail + SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=20" + sshpass -p "$PASS" sftp $SSH_OPTS "$USER@$HOST" <<'SFTP' + -mkdir dev + -mkdir dev/fdroid + SFTP + # Publish the signed repo/ plus metadata/ (descriptions, screenshots, + # per-version changelogs) so changelog history survives across + # releases. keystore.p12 and config.yml are NEVER uploaded, so they + # can't re-enter the web-served tree; nginx serves only repo/ anyway. + sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/" + + # Archive the R8 mapping so user crash stacktraces stay deobfuscatable. + # Attached to the Gitea release (it's not an APK, so it fits the + # no-binaries rule). Best-effort: never fail a release over it. + - name: Attach R8 mapping to Gitea release + if: startsWith(github.ref, 'refs/tags/') + continue-on-error: true + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: | + set -e + MAP="app/build/outputs/mapping/release/mapping.txt" + if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi + TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" + ASSET="mapping-${VERSION:-$TAG}.txt.gz" + gzip -c "$MAP" > "/tmp/$ASSET" + # The release is created by the gitea-release job; ensure it exists + # (idempotent) so this job doesn't race it to a 404. + ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty') + if [ -z "$ID" ]; then + ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \ + "$API/releases" | jq -r '.id // empty') + fi + if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi + # Replace any prior asset of the same name (re-run safe). + OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \ + | jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id') + [ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true + curl -s -X POST -H "Authorization: token $TOKEN" \ + -F "attachment=@/tmp/$ASSET" \ + "$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n" + + # A Gitea release per tag, carrying the tag's CHANGELOG section as its + # notes. Deliberately no APK assets — distribution stays with the F-Droid + # repo; the release is the human-readable record. Gated on the tests-only + # ci job (not the deploy) so notes appear even if the F-Droid upload has + # an infrastructure hiccup. + gitea-release: + needs: ci + if: startsWith(github.ref, 'refs/tags/') + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract changelog section for this tag + run: | + set -e + TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" + VERSION="${TAG#v}" + # Everything between "## []" and the next "## [" heading. + awk -v ver="$VERSION" ' + $0 ~ "^## \\[" ver "\\]" { flag = 1; next } + /^## \[/ { flag = 0 } + flag' CHANGELOG.md > release-notes.md + # Trim leading blank lines. + sed -i -e '/./,$!d' release-notes.md + if [ ! -s release-notes.md ]; then + echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md + fi + echo "--- release notes ---" + cat release-notes.md + + - name: Create Gitea release + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: | + set -e + TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" + python3 - "$TAG" <<'PY' > payload.json + import json, sys + print(json.dumps({ + "tag_name": sys.argv[1], + "name": sys.argv[1], + "body": open("release-notes.md").read(), + "draft": False, + "prerelease": False, + })) + PY + # Upsert: the build-and-deploy job may have created a bare release + # first (to attach the mapping asset), so PATCH the notes if it + # exists, otherwise POST a new one. Both paths are re-run safe. + curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json + ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true) + if [ -n "$ID" ]; then + CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @payload.json "$API/releases/$ID") + OK=200 + else + CODE=$(curl -s -o response.json -w '%{http_code}' -X POST \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d @payload.json "$API/releases") + OK=201 + fi + cat response.json + if [ "$CODE" != "$OK" ]; then + echo "Release upsert failed with HTTP $CODE (expected $OK)" + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05f0cb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log files +*.log + +# Android Studio / IntelliJ +*.iml +.idea/ +.navigation/ +captures/ +.externalNativeBuild/ +.cxx/ + +# Keystore files +*.jks +*.keystore +*.p12 +/key.properties + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# OS files +.DS_Store +Thumbs.db + +# F-Droid local artifacts (the pipeline generates them in CI) +/fdroid/ + +# KSP +.ksp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c00359 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is +the source of truth for version codes (see Calendula's `docs/RELEASING.md`). + +## [Unreleased] + +### Added +- M0 skeleton: project scaffolding copied from Calendula (Gradle, version + catalog, Hilt, Material 3 Expressive theme, Gitea CI/release workflows), + reseeded to a warm-mauve palette. Builds and shows a themed placeholder. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c6dd179 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jean-Luc Makiola + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b343c0 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +
+ +

Floret

+ +

A modern Material 3 Expressive task app for Android.
+Reads, writes, and reminds — on top of an existing tasks provider, with no own +sync stack.

+ +Android 10+ +Kotlin + Compose +Material 3 Expressive +MIT License + +
+ +Floret is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). +Where Calendula is a pure front-end over Android's `CalendarContract`, Floret is +a pure front-end over the **OpenTasks `TaskContract` provider** — the store that +DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own +database, no reinvented sync. + +A Calendula flower head is botanically made of many small *florets* — the +individual items that make up the bloom. Floret is those items: your tasks. + +> **Status: M0 — skeleton.** Builds and shows a themed placeholder. The +> `TaskContract` data layer, task screens, and reminder engine are the next +> milestones. See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap and the +> A-now-B-later architecture (front-end first; bundle the provider later). + +## Sync sources (by design) + +Floret works with anything that writes to the tasks provider — **DAVx5** +(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync +adapter — because it builds on the provider, not on any one sync app. Google +Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean +owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are the lane. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c756ce1 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,138 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties().apply { + if (keystorePropertiesFile.exists()) { + load(FileInputStream(keystorePropertiesFile)) + } +} + +android { + namespace = "de.jeanlucmakiola.floret" + compileSdk = 37 + + defaultConfig { + applicationId = "de.jeanlucmakiola.floret" + minSdk = 29 + targetSdk = 36 + // The git tag is the single source of truth for released builds: at + // release time .gitea/workflows/release.yaml derives both fields from + // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH + // (e.g. v2.0.0 -> 20000). These committed values are the dev/local + // default; keep them matching the latest released tag. See docs/RELEASING.md. + versionCode = 100 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } + } + debug { + applicationIdSuffix = ".debug" + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.compose.material.icons.extended) + + implementation(libs.hilt.android) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.hilt.compiler) + + implementation(libs.androidx.datastore.preferences) + + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.coroutines.core) + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) + testImplementation(libs.turbine) + testImplementation(libs.kotlinx.coroutines.test) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.truth) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..dccc1da --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,6 @@ +# Keep Hilt-generated classes +-keep class dagger.hilt.** { *; } +-keep @dagger.hilt.android.HiltAndroidApp class * + +# Compose Compiler may keep its own; defaults are fine +-dontwarn org.jetbrains.annotations.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1135266 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/floret/FloretApp.kt b/app/src/main/java/de/jeanlucmakiola/floret/FloretApp.kt new file mode 100644 index 0000000..4a2807d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/FloretApp.kt @@ -0,0 +1,11 @@ +package de.jeanlucmakiola.floret + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +/** + * Application entry point. Registered as android:name=".FloretApp" + * in AndroidManifest.xml. Hilt initializes its component graph here. + */ +@HiltAndroidApp +class FloretApp : Application() diff --git a/app/src/main/java/de/jeanlucmakiola/floret/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/floret/MainActivity.kt new file mode 100644 index 0000000..ef6e274 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/MainActivity.kt @@ -0,0 +1,30 @@ +package de.jeanlucmakiola.floret + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.floret.ui.RootScreen +import de.jeanlucmakiola.floret.ui.theme.FloretTheme + +/** + * Single activity. M0 hosts only a themed placeholder scaffold; the task + * screens, theme-mode wiring (SettingsViewModel) and notification/widget + * intent routing land in later milestones — mirroring Calendula's MainActivity. + */ +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + FloretTheme { + RootScreen(modifier = Modifier.fillMaxSize()) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt new file mode 100644 index 0000000..3470fd0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/RootScreen.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.floret.ui + +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.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.floret.R + +/** + * M0 placeholder root. Proves the theme, Hilt entry point and edge-to-edge + * scaffold render. Replaced in M1 by the lists overview + nav host + * (FloretHost), the analog of Calendula's CalendarHost. + */ +@Composable +fun RootScreen(modifier: Modifier = Modifier) { + Scaffold(modifier = modifier) { inner -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(inner) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.app_tagline), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Color.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Color.kt new file mode 100644 index 0000000..0ec5a69 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Color.kt @@ -0,0 +1,46 @@ +package de.jeanlucmakiola.floret.ui.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +/** + * Seed color anchoring the entire palette. A warm mauve — sibling to + * Calendula's cool slate ([0xFF5C6B7A]) and deliberately distinct from + * HouseHoldKeaper's sage. Evokes the marigold florets a Calendula head is + * made of, kept muted to match the family's restrained aesthetic. + * + * Placeholder until branding lands; refine alongside the launcher icon. + */ +val FloretSeed: Color = Color(0xFF7A5C6B) + +/** + * Fallback light scheme for devices without dynamic color (API < 31) or when + * the user disables it. Dynamic color (API 31+) is the real path; these are a + * hand-picked approximation of the tonal palette generated from [FloretSeed]. + */ +val FloretLightFallback = lightColorScheme( + primary = Color(0xFF6A4E5B), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFF6D9E5), + onPrimaryContainer = Color(0xFF2A101C), + secondary = Color(0xFF705560), + onSecondary = Color(0xFFFFFFFF), + background = Color(0xFFFEFBFC), + onBackground = Color(0xFF1F1A1C), + surface = Color(0xFFFEFBFC), + onSurface = Color(0xFF1F1A1C), +) + +val FloretDarkFallback = darkColorScheme( + primary = Color(0xFFE2BAC9), + onPrimary = Color(0xFF402533), + primaryContainer = Color(0xFF583B49), + onPrimaryContainer = Color(0xFFF6D9E5), + secondary = Color(0xFFD8BCC6), + onSecondary = Color(0xFF3A2831), + background = Color(0xFF161113), + onBackground = Color(0xFFE9E0E3), + surface = Color(0xFF161113), + onSurface = Color(0xFFE9E0E3), +) diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Theme.kt new file mode 100644 index 0000000..aef59fc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Theme.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.floret.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +/** + * App theme. Honors: + * - System light/dark. + * - Dynamic Color on API 31+, else falls back to the hand-tuned scheme + * derived from [FloretSeed]. + * + * Mirrors Calendula's theme: MaterialExpressiveTheme routes all component + + * custom motion through MaterialTheme.motionScheme. STANDARD over expressive() + * is the same deliberate choice — spring choreography without the overshoot. + * + * A Settings screen can later override dynamicColor and theme mode; the M0 + * foundation just follows the system. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun FloretTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val ctx = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) + } + darkTheme -> FloretDarkFallback + else -> FloretLightFallback + } + + MaterialExpressiveTheme( + colorScheme = colorScheme, + typography = FloretTypography, + motionScheme = MotionScheme.standard(), + content = content, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Type.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Type.kt new file mode 100644 index 0000000..4ed8542 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/theme/Type.kt @@ -0,0 +1,10 @@ +package de.jeanlucmakiola.floret.ui.theme + +import androidx.compose.material3.Typography + +/** + * Default Material 3 Expressive typography. A custom font + tuned scale will + * land in a later UI-design iteration; the defaults keep the M0 foundation lean + * (same posture as Calendula's scaffolding). + */ +val FloretTypography = Typography() diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..5912048 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + 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..c72b21d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,22 @@ + + + + + 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..b3e26b4 --- /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..b3e26b4 --- /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/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..2498237 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #FF7A5C6B + + #FF7A5C6B + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6173882 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Floret + A modern Material 3 Expressive task app.\nComing into bloom. + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2b18c64 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..87d1f20 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..c6ba7b9 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a96f72d --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false +} diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..a8c0b46 --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,319 @@ +# Floret — implementation plan + +> A modern Material 3 Expressive **task** app for Android. Reads, writes, and +> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync / +> DecSync over CalDAV), with no own sync stack. +> +> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`; +> Floret is a to-do list over the **OpenTasks `TaskContract` provider**. A +> Calendula flower head is made of many small *florets* — the individual items +> that make up the bloom. + +Working identifiers (placeholder, easily renamed): +`applicationId = de.jeanlucmakiola.floret`, app name **Floret**. + +--- + +## 0. The thesis this app embodies + +A nice M3-Expressive front end over open backends, no reinvented storage or +sync. Calendula proved the pattern against the OS calendar provider. Floret +applies it to tasks. The crucial difference: **there is no OS tasks provider**, +so we depend on a tasks *provider app* being present — exactly as Calendula +depends on a sync app like DAVx5 for CalDAV. + +### Posture A now, Posture B long-term (locked decision) + +- **A (this plan):** pure front-end over whatever tasks provider is installed + (OpenTasks / tasks.org / jtx). Fast to ship; requires a provider app present. +- **B (later):** bundle the Apache-2.0 `opentasks-provider` so the app is + self-contained (owns the `org.dmfs.tasks` authority + `org.dmfs.permission.*`, + DAVx5 syncs directly into it). Bundling the provider bundles **storage, not + sync** — external CalDAV engines still feed it, which keeps us true to the + thesis. + +**The one rule that makes B additive instead of a rewrite:** the entire app +talks to a `TasksRepository`; only *one* class (`OpenTasksDataSource`) knows +about a `ContentResolver`, `TaskContract`, or an authority string, and it +resolves its authority at **runtime** via `ProviderResolver`. In A the resolver +finds the installed provider; in B it finds our own bundled one. Repo + UI + +domain never change. **Never let `TaskContract` column names or the authority +string leak above the data layer.** + +--- + +## 1. What transfers from Calendula + +Calendula's layering is the template. Lift these **verbatim or near-verbatim**: + +| Area | From Calendula | Change for Floret | +|---|---|---| +| Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only | +| Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical | +| Theme | `ui/theme/Theme.kt` (`MaterialExpressiveTheme`, `MotionScheme.standard()`, dynamic color + hand-tuned fallback), `Type.kt`, `Color.kt` | reseed fallback palette | +| DI shape | `data/di/DataModule.kt` (`DataBindModule` + `DataProvideModule`), `@IoDispatcher` qualifier, DataStore wiring | rename store | +| Data seam pattern | `CalendarRepository` (Flow API) + `CalendarRepositoryImpl` wrapping a `CalendarDataSource` interface + `AndroidCalendarDataSource` (Cursor/ContentObserver), `Projections`, `ColumnReader`, mappers | retarget to `TaskContract` | +| Reactive flows | `ContentObserver` → `callbackFlow` bridge in the data source | observe tasks URI | +| Notifications | `ReminderNotifier` (channel, POST_NOTIFICATIONS gate, dedupe by tag) | reuse almost as-is | +| Prefs | `data/prefs/SettingsPrefs`, DataStore | reuse | +| Settings/onboarding/permission UI | `ui/settings`, `ui/permission` (`OnboardingScaffold`, `PermissionScreen`) | adapt copy + permissions | +| Widgets | Glance `widget/` scaffolding (receiver + `PROVIDER_CHANGED` refresh) | task-list widget | +| Common UI | `ui/common/*` (GroupedList, InlineTextField, OptionCard, ColorSwatchRow, FailureView, transitions) | reuse | +| Test stack | JUnit5 + Truth + Turbine + coroutines-test; data source as a JVM-testable seam | reuse | + +**Net: ~70–80% of the scaffolding is a copy.** The genuinely new code is the +`TaskContract` data layer, the task screens, and a **self-scheduled reminder +engine** (see §6 — the one place Calendula's pattern does *not* carry over). + +### What does NOT exist here (why Floret is simpler than Calendula) + +No month/week/day grid rendering. No recurrence-scoped writes ("this & following" +vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly- +nested list with a due date and a checkbox. + +--- + +## 2. Module & package layout + +Single `:app` module for A (mirrors Calendula). Package root +`de.jeanlucmakiola.floret`. + +``` +domain/ + Models.kt TaskList, Task, TaskStatus, Priority, SubtaskRelation + TaskForm.kt validated create/edit form (mirrors EventForm) + Filters.kt smart lists: Today, Upcoming, Overdue, Completed, All +data/ + di/ Qualifiers.kt, DataModule.kt (copy + retarget) + tasks/ + TasksContract.kt vendored subset of OpenTasks TaskContract (Apache-2.0) + ProviderResolver.kt detect installed provider authority + permissions ← the A/B seam + TaskProjections.kt column lists + ColumnReader.kt (copy from Calendula) + TaskMapper.kt cursor row -> domain + TaskWriteMapper.kt form -> ContentValues + TasksDataSource.kt interface (JVM-testable seam) + OpenTasksDataSource.kt ContentResolver/ContentObserver impl + TasksRepository.kt Flow API (interface) + TasksRepositoryImpl.kt wraps the data source + reminders/ + DueReminderScheduler.kt AlarmManager scheduling (NEW — see §6) + DueReminderReceiver.kt alarm fires -> post notification + BootRescheduleReceiver.kt + provider-change reschedule + TaskNotifier.kt (copy ReminderNotifier, retarget) + prefs/ SettingsPrefs (copy) +ui/ + theme/ (copy) + common/ (copy relevant pieces) + lists/ ListsScreen + ViewModel + UiState (overview of lists/accounts) + tasklist/ TaskListScreen (one list or a smart list) + VM + UiState + detail/ TaskDetailScreen + VM + UiState + edit/ TaskEditScreen + VM + UiState + settings/ (copy + adapt) + permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding + FloretHost.kt nav host (mirrors CalendarHost) + RootScreen.kt +widget/ Glance task widget (later milestone) +FloretApp.kt, MainActivity.kt +``` + +--- + +## 3. The data layer (the heart) + +### 3.1 Provider targeting — `ProviderResolver` + +Known providers, in preference order, each = (authority, read perm, write perm): + +| Provider | Authority | Permissions | Contract | +|---|---|---|---| +| OpenTasks | `org.dmfs.tasks` | `org.dmfs.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks TaskContract | +| tasks.org | `org.tasks.opentasks` *(verify on device)* | `org.tasks.permission.READ_TASKS` / `WRITE_TASKS` | OpenTasks-compatible | +| jtx Board | `at.techbee.jtx` | `at.techbee.jtx.permission.READ` / `WRITE` | jtx (richer, different) | + +`ProviderResolver.detect()`: +1. `PackageManager.resolveContentProvider(authority, 0)` for each known authority. +2. Return the first present as the active `TaskProvider(authority, readPerm, writePerm)`. +3. None present → `null` → drives the "install a tasks provider" onboarding (§5). + +**v1 scope:** fully support the **OpenTasks contract** (covers OpenTasks + +tasks.org's compatible provider). jtx is detected but treated as "supported +later" (its contract differs). For B, the resolver simply also finds our bundled +`org.dmfs.tasks`. + +> Verify the exact tasks.org provider authority on a real device before relying +> on it — docs are inconsistent; OpenTasks (`org.dmfs.tasks`) is the certain one. + +### 3.2 `TasksContract.kt` + +Vendor the subset we use from the Apache-2.0 OpenTasks `TaskContract` (don't take +a runtime dep on OpenTasks). Authority is injected, not hardcoded. Tables/columns: + +- **TaskLists**: `_ID`, `LIST_NAME`, `LIST_COLOR`, `ACCOUNT_NAME`, `ACCOUNT_TYPE`, + `SYNC_ENABLED`, `VISIBLE`, `OWNER`. (Account fields → group lists by account in + the UI, like Calendula groups calendars.) +- **Tasks** (the denormalized instances view): `_ID`, `LIST_ID`, `TITLE`, + `DESCRIPTION`, `DTSTART`, `DUE`, `IS_ALLDAY`, `TZ`, `STATUS`, `PRIORITY`, + `PERCENT_COMPLETE`, `COMPLETED`, `RRULE`, `LIST_COLOR`, `ACCOUNT_*`. +- **Properties / Relation** (`Tasks.Properties`, mimetype Relation): subtasks via + `RELATED-TO` (`RELATION_TYPE_PARENT`). This is how DAVx5 carries the hierarchy. + +### 3.3 Repository API + +```kotlin +interface TasksRepository { + fun taskLists(): Flow> + fun tasks(filter: TaskFilter): Flow> // list-id or smart list + suspend fun taskDetail(taskId: Long): TaskDetail + suspend fun createTask(form: TaskForm): Long + suspend fun updateTask(taskId: Long, original: TaskForm, updated: TaskForm) + suspend fun setCompleted(taskId: Long, completed: Boolean) // the core gesture + suspend fun deleteTask(taskId: Long) + suspend fun createLocalList(name: String, color: Int): Long // device-only list we own + // subtasks: createSubtask(parentId, form) / reparent via RELATED-TO +} +``` + +Writes go through the **sync-adapter URI form** where the provider requires it +(local/unsynced lists), mirroring Calendula's `createLocalCalendar`. Completion = +set `STATUS=COMPLETED`, `PERCENT_COMPLETE=100`, `COMPLETED=now`; DAVx5 syncs it +back out as a normal VTODO status change. + +### 3.4 Reactive flows + +`OpenTasksDataSource` registers a `ContentObserver` on the active authority's +Tasks/TaskLists URIs and emits via `callbackFlow` — identical mechanism to +Calendula, so external sync (DAVx5 pulling new tasks) updates the UI live, and +multiple sync sources coexist in one list. + +--- + +## 4. Screens (Compose, M3 Expressive) + +1. **Lists overview** (`ui/lists`) — smart lists at top (Today, Upcoming, + Overdue, All), then user lists grouped by account (reuse `GroupedList`). + Per-list color dot + open/undone count. +2. **Task list** (`ui/tasklist`) — the workhorse. Checkbox rows, swipe-to- + complete + swipe-to-delete, inline "add task" field (reuse `InlineTextField`), + subtask indentation, sort (due/priority/manual), section headers for smart + lists (Overdue / Today / Later). FAB to add. +3. **Task detail** (`ui/detail`) — title, notes, due/start, priority, + percent-complete, subtasks, source list/account, completed timestamp. +4. **Task edit** (`ui/edit`) — title, notes, list picker, due/start date-time + (reuse Calendula date/time pickers), priority, reminder offset, subtasks. + "Conflict-safe save" like Calendula (re-check before overwrite). +5. **Settings** (`ui/settings`) — theme/dynamic-color/language (copy), default + list, default reminder offset, "tasks app" info + manage button. +6. **Onboarding / permission** (`ui/permission`) — see §5. + +Material 3 Expressive throughout: `MaterialExpressiveTheme`, +`MotionScheme.standard()`, expressive checkbox/FAB/swipe motion. Follow the +`material-3` skill for component choices (M3 `ListItem` for rows, etc.). + +--- + +## 5. Onboarding & permissions + +Three gates, in order: + +1. **Provider present?** `ProviderResolver.detect()`. If none → a screen + explaining Floret needs a tasks provider, with one-tap links to install + **OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app". + (This is Floret's "needs DAVx5" moment. Disappears entirely under Posture B.) +2. **Tasks read/write permission** — request the active provider's runtime perms + (`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at + runtime; resolved dynamically from the detected provider. +3. **POST_NOTIFICATIONS + exact-alarm** — for due reminders (reuse Calendula's + reminder onboarding). + +`` in the manifest for package visibility (launch DAVx5 / OpenTasks), +exactly like Calendula's launcher query. + +--- + +## 6. Reminders — the one pattern that does NOT carry over + +Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and +just posts the notification (Etar model). **Tasks providers do not broadcast +reminders.** So Floret must schedule its own: + +- `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` / + `SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen + offset (and optionally at `DTSTART`). +- `DueReminderReceiver` fires → posts via `TaskNotifier` (the copied + `ReminderNotifier`), tapping opens the task. +- **Reschedule triggers:** boot (`RECEIVE_BOOT_COMPLETED`), and the data-source + `ContentObserver` firing on any task change (our edits *and* external sync) → + recompute alarms for the near-future window. Keep a small scheduled-alarm + registry in DataStore so we can diff like Calendula diffs reminder rows. + +This is the single largest *new* subsystem. Everything downstream of it (channel, +notification building, POST_NOTIFICATIONS gating, dedupe-by-tag) is copied. + +--- + +## 7. Manifest (vs Calendula) + +```xml + + + + + + + + + … LAUNCHER intent (package visibility) … +``` +Receivers: `DueReminderReceiver`, `BootRescheduleReceiver`, Glance widget +receiver. **No `EVENT_REMINDER` receiver** (doesn't apply). + +--- + +## 8. Milestones + +- **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from + Calendula, rename to Floret. Builds, shows themed empty scaffold. *(~½ day)* +- **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource` + + repository. Lists overview + task list render real synced tasks (read-only), + live-updating via ContentObserver. *(the meaty milestone)* +- **M2 — Complete & CRUD.** Toggle complete (swipe + checkbox), create via inline + field + edit screen, delete. Local-list creation. +- **M3 — Detail/edit polish.** Due/start pickers, priority, percent, conflict-safe + saves, smart lists (Today/Upcoming/Overdue). +- **M4 — Subtasks.** `RELATED-TO` read + indentation; create/reparent. *(trickiest)* +- **M5 — Reminders.** `DueReminderScheduler` + receivers + onboarding. +- **M6 — Settings, widget, i18n, F-Droid metadata, CI** (clone Calendula's + `.gitea/workflows`, `fdroid-metadata/`, RELEASING docs). +- **B (separate, later):** add `:provider` module bundling Apache-2.0 + `opentasks-provider`; `ProviderResolver` default authority becomes our own + `org.dmfs.tasks`; add sync-adapter permissions; ship self-contained. UI/repo + untouched. + +**Effort:** M0–M3 is a small, fast core (days, given the Calendula head start). +M4 (subtasks) and M5 (reminders) are the two spots needing real thought. + +--- + +## 9. Open decisions / to verify + +1. **Name** — `Floret` is the working title (florets compose a Calendula head). + Confirm or replace before M0 (touches appId, namespace, package). +2. **tasks.org provider authority** — verify on a real device; OpenTasks + (`org.dmfs.tasks`) is the certain target for v1. +3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? +4. **B authority choice** — bundling `org.dmfs.tasks` makes Floret a *replacement* + for OpenTasks (one authority owner per device). Intended (one app instead of + two), but a conscious choice. +5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI. + +--- + +## 10. Sync sources Floret inherits for free (README copy) + +Because Floret builds on the provider, not on any one sync app, it works with +**anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync, +CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration. +Google Tasks / Microsoft To Do are out of scope by design (proprietary, would +mean owning a sync stack). Open standards — CalDAV / iCalendar / DecSync — are +the lane. diff --git a/fdroid-metadata/de.jeanlucmakiola.floret.yml b/fdroid-metadata/de.jeanlucmakiola.floret.yml new file mode 100644 index 0000000..500c63c --- /dev/null +++ b/fdroid-metadata/de.jeanlucmakiola.floret.yml @@ -0,0 +1,10 @@ +AuthorName: Jean-Luc Makiola +License: MIT +Name: Floret +Summary: A modern Material 3 Expressive task app for Android. + +Categories: + - Time + +SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/floret +IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/floret/issues diff --git a/fdroid-metadata/de.jeanlucmakiola.floret/en-US/description.txt b/fdroid-metadata/de.jeanlucmakiola.floret/en-US/description.txt new file mode 100644 index 0000000..1837305 --- /dev/null +++ b/fdroid-metadata/de.jeanlucmakiola.floret/en-US/description.txt @@ -0,0 +1,10 @@ +Floret is a modern, open-source task app for Android. It works directly on an +existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to +your device via DAVx5, SmoothSync or DecSync show up automatically, and changes +you make sync back the same way — no own account, no own sync. + +The differentiator is the design: real Material 3 Expressive throughout, with +dynamic color, expressive motion, and expressive shapes. Sibling to Calendula. + +Privacy: zero telemetry, no analytics, no network access of its own — your data +never leaves the device except through the sync app you already trust. diff --git a/fdroid-metadata/de.jeanlucmakiola.floret/en-US/summary.txt b/fdroid-metadata/de.jeanlucmakiola.floret/en-US/summary.txt new file mode 100644 index 0000000..c3a2b18 --- /dev/null +++ b/fdroid-metadata/de.jeanlucmakiola.floret/en-US/summary.txt @@ -0,0 +1 @@ +A modern Material 3 Expressive task app for Android. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7a6f3a1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# Required for AndroidX. +android.useAndroidX=true + +# Kotlin code style for this project: "official" or "obsolete". +kotlin.code.style=official + +# Enables namespacing of each library's R class, less RAM, faster builds. +android.nonTransitiveRClass=true + +# Kotlin incremental compilation (default; kept explicit for documentation). +kotlin.incremental=true + +# Reproducible builds for F-Droid. +android.uniquePackageNames=true + +# Performance: enable parallel project execution and build cache (stable in Gradle 9). +org.gradle.parallel=true +org.gradle.caching=true +# Configuration cache: compatible with AGP 9.1; opt in when all plugins confirm support. +# org.gradle.configuration-cache=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..baa28d1 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..0aee61f --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,95 @@ +[versions] +agp = "9.2.1" +kotlin = "2.3.21" +ksp = "2.3.9" +hilt = "2.59.2" +coreKtx = "1.19.0" +appcompat = "1.7.1" +lifecycleRuntime = "2.10.0" +activityCompose = "1.13.0" +composeBom = "2026.05.01" +# Material 3 Expressive APIs currently live only in the 1.5 alpha line. +# Pin explicitly to override the BOM (which ships stable 1.4.0). +# Re-evaluate when 1.5.0 stable lands. +material3 = "1.5.0-alpha21" +datastore = "1.2.1" +junit = "6.1.0" +junitPlatform = "6.1.0" +truth = "1.4.5" +androidxJunit = "1.3.0" +espressoCore = "3.7.0" +kotlinxDatetime = "0.7.0" +kotlinxCoroutines = "1.10.2" +turbine = "1.2.0" +hiltNavigationCompose = "1.3.0" +lifecycleCompose = "2.10.0" +androidxTestRules = "1.7.0" +# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). +glance = "1.1.1" + +[libraries] +# AndroidX core +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +# Compose BOM + libs +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } + +# Material 3 (Expressive lives in this artifact for 1.5+) +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-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-android-compiler", version.ref = "hilt" } + +# DataStore +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# Unit tests +junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } +junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } +junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" } +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } + +# Android tests +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } + +# Domain time +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } + +# Coroutines (transitively pulled by hilt-android, pinned explicit) +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } + +# Test - Flow assertions +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } + +# Hilt navigation-compose (for hiltViewModel() in Composables) +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + +# Lifecycle compose (for collectAsStateWithLifecycle) +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } + +# Glance — Jetpack home-screen widgets (Compose-like RemoteViews) +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } + +# Android tests - GrantPermissionRule +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b21b69e --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# 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 ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@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=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@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="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..1c37bac --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Floret" +include(":app")