diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml new file mode 100644 index 00000000..3050b473 --- /dev/null +++ b/.github/workflows/build-nightly.yml @@ -0,0 +1,85 @@ +name: Nightly Build & Log + +# Builds the signed nightly APK on a schedule and records the result in the +# website build log (website/data/nightly_builds.json). Committing that file to +# main touches website/**, which triggers deploy-website.yml to refresh the +# published site. This complements the push-triggered F-Droid deploy +# (nightlydepolyci.yml); the two can be unified later. + +on: + schedule: + - cron: '0 2 * * *' # 02:00 UTC daily + workflow_dispatch: + +permissions: + contents: write # commit the updated build log back to main + +jobs: + nightly: + name: Build nightly APK and record build log + runs-on: ubuntu-latest + steps: + - name: Free Disk Space (Ubuntu) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: false # keep Android SDKs for Flutter + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17.x' + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.29.2' + + - name: Get dependencies + run: flutter pub get + + - name: Decode signing secrets + env: + NIGHTLY_KEYSTORE_B64: ${{ secrets.NIGHTLY_KEYSTORE_B64 }} + NIGHTLY_PROPERTIES_B64: ${{ secrets.NIGHTLY_PROPERTIES_B64 }} + run: | + echo "$NIGHTLY_KEYSTORE_B64" | base64 --decode > android/nightly.jks + echo "$NIGHTLY_PROPERTIES_B64" | base64 --decode > android/key_nightly.properties + + - name: Build nightly APK + id: build + run: flutter build apk --flavor nightly --build-number=${{ github.run_number }} --release + + - name: Record successful build + if: success() + run: | + python3 scripts/update_build_log.py success \ + "https://github.com/${{ github.repository }}/raw/fdroid-repo/repo/nightly.${{ github.run_number }}.apk" \ + "${{ github.run_number }}" + + - name: Record failed build + if: failure() + run: python3 scripts/update_build_log.py failure "" "${{ github.run_number }}" + + - name: Commit build log + if: always() + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git diff --quiet -- website/data/nightly_builds.json; then + echo "No build-log changes to commit." + else + git add website/data/nightly_builds.json + git commit -m "chore(nightly): record build ${{ github.run_number }}" + git push + fi diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 00000000..b5fa4d79 --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,64 @@ +name: Deploy Website + +# Builds the Hugo site in website/ and publishes it to GitHub Pages. +# Triggered when the site content or this workflow changes (the nightly +# build-log commit made by build-nightly.yml touches website/data/, so a new +# nightly automatically refreshes the published site). + +on: + push: + branches: [main] + paths: + - 'website/**' + - '.github/workflows/deploy-website.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress production deploy. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + env: + HUGO_VERSION: 0.164.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # hugo.toml enableGitInfo needs full history + + - name: Setup Hugo + uses: peaceiris/actions-hugo@v3 + with: + hugo-version: ${{ env.HUGO_VERSION }} + extended: true + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Build site + run: hugo --source website --minify --baseURL "${{ steps.pages.outputs.base_url }}/" + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: website/public + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/nightlydepolyci.yml b/.github/workflows/nightlydepolyci.yml index e47359b8..c8e92ad4 100644 --- a/.github/workflows/nightlydepolyci.yml +++ b/.github/workflows/nightlydepolyci.yml @@ -4,6 +4,13 @@ on: push: branches: - main + # Website content and the nightly build-log commit must not trigger a full + # APK rebuild + F-Droid redeploy (see build-nightly.yml / deploy-website.yml). + paths-ignore: + - 'website/**' + - 'scripts/**' + - '.github/workflows/deploy-website.yml' + - '.github/workflows/build-nightly.yml' jobs: build-and-deploy: diff --git a/scripts/update_build_log.py b/scripts/update_build_log.py new file mode 100644 index 00000000..a2542037 --- /dev/null +++ b/scripts/update_build_log.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Record a nightly build in the website's build-log data file. + +Prepends an entry describing the current commit to +``website/data/nightly_builds.json`` (newest first) and keeps only the most +recent ``MAX_ENTRIES``. The Hugo ``nightly-builds`` shortcode renders this file. + +Run from the repository root (as the CI workflow does): + + python3 scripts/update_build_log.py + +All arguments are optional: + status "success" (default) or "failure" + artifact_url link to the built APK ("" if none) + build_number CI run number ("" if unknown) +""" + +import json +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +LOG = Path("website/data/nightly_builds.json") +MAX_ENTRIES = 90 + + +def _git(*args: str) -> str: + return subprocess.check_output(["git", *args]).decode().strip() + + +def main(status: str = "success", artifact: str = "", build_number: str = "") -> None: + entry = { + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "sha": _git("rev-parse", "HEAD")[:8], + "msg": _git("log", "-1", "--pretty=%s"), + "status": status, + "artifact": artifact, + "build": build_number, + } + + entries = [] + if LOG.exists(): + try: + entries = json.loads(LOG.read_text() or "[]") + except json.JSONDecodeError: + entries = [] + + entries.insert(0, entry) + entries = entries[:MAX_ENTRIES] + + LOG.parent.mkdir(parents=True, exist_ok=True) + LOG.write_text(json.dumps(entries, indent=2) + "\n") + print( + f"Recorded nightly build {entry['sha']} ({status}); " + f"{len(entries)} entr{'y' if len(entries) == 1 else 'ies'} in {LOG}." + ) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 00000000..454de3e4 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,4 @@ +# Hugo build output and caches +/public/ +/resources/ +.hugo_build.lock diff --git a/website/README.md b/website/README.md new file mode 100644 index 00000000..0240d6ba --- /dev/null +++ b/website/README.md @@ -0,0 +1,74 @@ +# TaskWarrior Mobile — Project Website + +A self-contained [Hugo](https://gohugo.io) static site for the TaskWarrior Mobile +app: landing page, downloads (with an auto-updated **nightly build log**), and a +documentation section. It has **no external theme** — all layouts live in +`layouts/`, so it builds from the single Hugo binary with no submodules or network +fetches. + +## Local development + +Requires Hugo **extended** (pinned to the version in +[`.github/workflows/deploy-website.yml`](../.github/workflows/deploy-website.yml)): + +```bash +# from this directory +hugo server # live-reload dev server at http://localhost:1313 +hugo --minify # production build into ./public +``` + +## Layout + +``` +website/ +├── hugo.toml # site config (baseURL, menus, params) +├── content/ # Markdown pages (_index, downloads, docs) +├── layouts/ # self-contained templates (no theme) +│ ├── _default/ # baseof / single / list +│ ├── index.html # homepage +│ └── shortcodes/ +│ └── nightly-builds.html # renders data/nightly_builds.json +├── data/ +│ └── nightly_builds.json # build log (populated by CI; ships empty) +└── static/ + ├── css/style.css + └── CNAME # custom domain +``` + +## Nightly build log + +[`scripts/update_build_log.py`](../scripts/update_build_log.py) prepends an entry +`{ts, sha, msg, status, artifact, build}` to `data/nightly_builds.json` (keeping the +90 most recent). The `{{}}` shortcode on the downloads page +renders it. The file ships as `[]`; CI fills it in. + +## CI + +- **`build-nightly.yml`** — daily at 02:00 UTC (and on demand): builds the signed + nightly APK, records the result via `update_build_log.py`, and commits the updated + log to `main`. +- **`deploy-website.yml`** — on any push touching `website/**` (including the nightly + log commit): builds the site with Hugo and deploys it to GitHub Pages. +- `nightlydepolyci.yml` now ignores `website/**` and `scripts/**` so a build-log + commit does not trigger a redundant APK rebuild. + +## One-time infrastructure setup (needs repo-admin / DNS access) + +These cannot be done from code and must be configured by a maintainer: + +1. **Enable GitHub Pages** → repo *Settings → Pages → Build and deployment → + Source: **GitHub Actions***. +2. **Custom domain / DNS** — point `taskwarrior.ccextractor.org` at GitHub Pages + with a `CNAME` DNS record to `.github.io`. The site already ships a + `static/CNAME`, so Pages will pick up the domain on first deploy. +3. **Branch protection** — if `main` is protected, allow `github-actions[bot]` to + push the nightly build-log commit (or point that commit at an unprotected branch). +4. The nightly signing secrets (`NIGHTLY_KEYSTORE_B64`, `NIGHTLY_PROPERTIES_B64`) are + the same ones the existing F-Droid workflow already uses. + +> **Note on the deploy target.** The proposal described renaming the `fdroid-repo` +> branch to `public-site` and serving from a branch. This implementation instead uses +> the modern **GitHub Actions Pages deployment** (`actions/deploy-pages`), which keeps +> the site source in `website/` on `main` and needs no dedicated deploy branch — fewer +> moving parts and no history juggling. The F-Droid APK repo on `fdroid-repo` is +> untouched. diff --git a/website/content/_index.md b/website/content/_index.md new file mode 100644 index 00000000..dd48717b --- /dev/null +++ b/website/content/_index.md @@ -0,0 +1,12 @@ +--- +title: "TaskWarrior Mobile" +--- + +**TaskWarrior Mobile** is the cross-platform [Flutter](https://flutter.dev) client for +[TaskWarrior](https://taskwarrior.org), maintained by [CCExtractor](https://ccextractor.org). +It brings TaskWarrior's fast, unobtrusive task management to Android, iOS, Windows, +macOS, and Linux, backed by [TaskChampion](https://github.com/GothenburgBitFactory/taskchampion) +for native, offline-first sync. + +New here? Head to the [downloads page](downloads/) to install the latest build, or browse +the [nightly build history](downloads/#nightly-builds) to see what has changed recently. diff --git a/website/content/docs/_index.md b/website/content/docs/_index.md new file mode 100644 index 00000000..5d0cc310 --- /dev/null +++ b/website/content/docs/_index.md @@ -0,0 +1,26 @@ +--- +title: "Documentation" +description: "Developer and user documentation for TaskWarrior Mobile." +--- + +Documentation for TaskWarrior Mobile. This section is a living skeleton that grows +alongside the app. + +## Architecture at a glance + +- **UI** — Flutter (Dart) with GetX for state management and routing. +- **Core** — a Rust FFI bridge (`tc_helper`, via `flutter_rust_bridge`) that wraps + [TaskChampion](https://github.com/GothenburgBitFactory/taskchampion) for task storage. +- **Sync** — offline-first: changes hit a local SQLite/TaskChampion replica immediately + and synchronise with a TaskChampion sync server when connectivity is available. + +## Getting the app + +See the [downloads page](../downloads/) for stable releases and nightly builds. + +## Contributing + +Contributions are welcome. Start with the +[CONTRIBUTING guide](https://github.com/CCExtractor/taskwarrior-flutter/blob/main/CONTRIBUTING.md) +in the repository, and join the [CCExtractor community](https://ccextractor.org/) on Slack +or Zulip. diff --git a/website/content/downloads.md b/website/content/downloads.md new file mode 100644 index 00000000..5029cd30 --- /dev/null +++ b/website/content/downloads.md @@ -0,0 +1,20 @@ +--- +title: "Downloads" +description: "Install TaskWarrior Mobile — stable releases and automatically-built nightly APKs." +--- + +## Stable releases + +Tagged releases are published on GitHub. Download the latest signed APK from the +[releases page](https://github.com/CCExtractor/taskwarrior-flutter/releases). + +## Nightly builds {#nightly-builds} + +A signed nightly APK is built automatically from the `main` branch every night and +published to the project's F-Droid repository. Each entry below links to the APK produced +by that build, newest first. + +{{< nightly-builds limit="20" >}} + +_Nightly builds are unstable snapshots intended for testing. For everyday use, prefer a +stable release above._ diff --git a/website/data/nightly_builds.json b/website/data/nightly_builds.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/website/data/nightly_builds.json @@ -0,0 +1 @@ +[] diff --git a/website/hugo.toml b/website/hugo.toml new file mode 100644 index 00000000..c66d1dfd --- /dev/null +++ b/website/hugo.toml @@ -0,0 +1,30 @@ +baseURL = "https://taskwarrior.ccextractor.org/" +locale = "en-us" +title = "TaskWarrior Mobile" +enableGitInfo = true + +# Self-contained site: layouts live in ./layouts, so no external theme (and thus +# no submodule / network fetch) is required — Hugo builds it from its single +# binary alone. + +[params] + description = "The cross-platform TaskWarrior mobile app by CCExtractor — offline-first task management with native TaskChampion sync." + repo = "https://github.com/CCExtractor/taskwarrior-flutter" + org = "https://ccextractor.org/" + +[markup.goldmark.renderer] + unsafe = true + +[menu] + [[menu.main]] + name = "Home" + url = "/" + weight = 1 + [[menu.main]] + name = "Downloads" + url = "/downloads/" + weight = 2 + [[menu.main]] + name = "Docs" + url = "/docs/" + weight = 3 diff --git a/website/layouts/_default/baseof.html b/website/layouts/_default/baseof.html new file mode 100644 index 00000000..0f314d08 --- /dev/null +++ b/website/layouts/_default/baseof.html @@ -0,0 +1,29 @@ + + + + + + {{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }} + + + + + + +
+ {{ block "main" . }}{{ end }} +
+ +
+

{{ .Site.Title }} · maintained by CCExtractor · built with Hugo

+
+ + diff --git a/website/layouts/_default/list.html b/website/layouts/_default/list.html new file mode 100644 index 00000000..8932309c --- /dev/null +++ b/website/layouts/_default/list.html @@ -0,0 +1,13 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{ .Content }} + {{ with .Pages }} + + {{ end }} +
+{{ end }} diff --git a/website/layouts/_default/single.html b/website/layouts/_default/single.html new file mode 100644 index 00000000..2eee85e2 --- /dev/null +++ b/website/layouts/_default/single.html @@ -0,0 +1,6 @@ +{{ define "main" }} +
+

{{ .Title }}

+ {{ .Content }} +
+{{ end }} diff --git a/website/layouts/index.html b/website/layouts/index.html new file mode 100644 index 00000000..3e9b59d5 --- /dev/null +++ b/website/layouts/index.html @@ -0,0 +1,29 @@ +{{ define "main" }} +
+

Your tasks, everywhere.

+

{{ .Site.Params.description }}

+

+ Get the app + View source +

+
+ +
+
+

Offline-first

+

Every create, edit, and complete is written to a local database instantly — no spinner, no network wait.

+
+
+

Native sync

+

Synchronise with a TaskChampion sync server through the native Rust bridge whenever you are online.

+
+
+

Cross-platform

+

One app across Android, iOS, Windows, macOS, and Linux, built with Flutter.

+
+
+ +
+ {{ .Content }} +
+{{ end }} diff --git a/website/layouts/shortcodes/nightly-builds.html b/website/layouts/shortcodes/nightly-builds.html new file mode 100644 index 00000000..3788f4ea --- /dev/null +++ b/website/layouts/shortcodes/nightly-builds.html @@ -0,0 +1,37 @@ +{{/* + nightly-builds shortcode — renders the build log collected by + scripts/update_build_log.py into website/data/nightly_builds.json. + Usage in content: {{< nightly-builds >}} (optionally {{< nightly-builds limit="15" >}}) +*/}} +{{ $builds := hugo.Data.nightly_builds }} +{{ $limit := int (.Get "limit" | default "20") }} +
+ {{ if $builds }} +
+ + + + + + + + + + + + {{ range first $limit $builds }} + + + + + + + + {{ end }} + +
Date (UTC)CommitMessageStatusAPK
{{ .ts }}{{ .sha }}{{ .msg }}{{ .status }}{{ with .artifact }}download{{ else }}—{{ end }}
+
+ {{ else }} +

No nightly builds have been recorded yet. Check back after the next scheduled build.

+ {{ end }} +
diff --git a/website/static/CNAME b/website/static/CNAME new file mode 100644 index 00000000..f71f76ef --- /dev/null +++ b/website/static/CNAME @@ -0,0 +1 @@ +taskwarrior.ccextractor.org diff --git a/website/static/css/style.css b/website/static/css/style.css new file mode 100644 index 00000000..4faed56e --- /dev/null +++ b/website/static/css/style.css @@ -0,0 +1,132 @@ +:root { + --bg: #0f1419; + --surface: #171d26; + --text: #e6e9ee; + --muted: #9aa4b2; + --accent: #7c5cff; + --accent-2: #4dd0e1; + --border: #263041; + --ok: #3fb950; + --fail: #f85149; + --radius: 12px; + --maxw: 900px; +} + +* { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +a { color: var(--accent-2); text-decoration: none; } +a:hover { text-decoration: underline; } + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.1em 0.4em; + font-size: 0.9em; +} + +/* Header */ +.site-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.brand { font-weight: 700; font-size: 1.15rem; color: var(--text); } +.brand span { color: var(--accent); } +.site-header nav { display: flex; flex-wrap: wrap; gap: 1.1rem; } +.site-header nav a { color: var(--muted); font-weight: 500; } +.site-header nav a:hover { color: var(--text); text-decoration: none; } + +/* Layout */ +.content { max-width: var(--maxw); margin: 0 auto; padding: 2.5rem 1.5rem 4rem; } + +/* Hero */ +.hero { text-align: center; padding: 2rem 0 1rem; } +.hero h1 { font-size: clamp(2rem, 6vw, 3rem); margin: 0 0 0.5rem; } +.tagline { color: var(--muted); font-size: 1.15rem; max-width: 640px; margin: 0 auto 1.5rem; } +.cta { display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap; } + +.button { + display: inline-block; + background: var(--accent); + color: #fff; + padding: 0.7rem 1.4rem; + border-radius: var(--radius); + font-weight: 600; +} +.button:hover { filter: brightness(1.1); text-decoration: none; } +.button-ghost { background: transparent; color: var(--text); border: 1px solid var(--border); } + +/* Features */ +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + margin: 2.5rem 0; +} +.feature { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; +} +.feature h3 { margin: 0 0 0.4rem; color: var(--accent-2); } +.feature p { margin: 0; color: var(--muted); } + +/* Pages */ +.page h1 { border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; } +.page-list { padding-left: 1.2rem; } + +/* Nightly build table */ +.table-scroll { overflow-x: auto; } +.nightly-builds table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-size: 0.92rem; +} +.nightly-builds th, .nightly-builds td { + text-align: left; + padding: 0.55rem 0.7rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +.nightly-builds th { color: var(--muted); font-weight: 600; } +.nowrap { white-space: nowrap; } +.empty { color: var(--muted); font-style: italic; } + +.status { + display: inline-block; + padding: 0.1em 0.6em; + border-radius: 999px; + font-size: 0.8em; + font-weight: 600; + text-transform: capitalize; +} +.status-success { background: rgba(63,185,80,0.15); color: var(--ok); } +.status-failure { background: rgba(248,81,73,0.15); color: var(--fail); } + +/* Footer */ +.site-footer { + border-top: 1px solid var(--border); + padding: 1.5rem; + text-align: center; + color: var(--muted); + font-size: 0.9rem; +}