Ctrl K
ComunidadGuías…

DEVELOPMENT.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Development

> Start here if you want to build, run, modify, or debug Moongate.

Moongate has two halves and they're set up independently:

| Half | Language | Where | What it does |
|---|---|---|---|
| **Mobile app** | Flutter (Dart) | `mobile/` | The Android app you install on your phone |
| **Klipper plugin** | Python | `klipper-plugin/` | A single-file Moonraker component that runs on your Pi |

For most code changes you only need to touch the mobile app. The plugin is small and stable.

---

## Quick start (mobile app)

```bash
git clone https://github.com/PEEKYPAUL/Moongate.git
cd Moongate/mobile
flutter pub get
flutter run                # debug build on a connected device/emulator
```

That's it for the app. If your phone is connected via ADB the app installs and launches in ~30 seconds.

---

## Prerequisites

| Tool | Version | Notes |
|---|---|---|
| **Flutter SDK** | ≥ 3.19 (stable) | <https://docs.flutter.dev/get-started/install> - install via the official installer, not Snap |
| **Android SDK** | API 34+ | Comes with Android Studio. Make sure SDK Platform-Tools and Build-Tools are installed |
| **JDK** | 17 | Android Studio bundles JDK 17 - use that one, not your system JDK |
| **ADB** | latest | Ships with platform-tools. Add `<sdk>/platform-tools/` to your `PATH` |
| **Git** | any modern | For cloning the repo |

Verify everything is reachable:

```bash
flutter doctor -v
adb --version
java -version          # should report 17.x
```

`flutter doctor` should be green on Flutter, Android toolchain, and at least one connected device or emulator before you proceed.

> Windows note: if the Java toolchain complains about `AF_UNIX` sockets, your `TEMP` path probably contains a short-name segment (e.g. `~1`). The repo includes [`run.ps1`](run.ps1) which sets `TEMP=C:\tmp` and pins JDK 17 - use that wrapper instead of plain `flutter` if you hit the issue.

---

## Connecting a phone for debugging

1. On the phone: **Settings → About phone → tap Build Number 7 times** to unlock Developer Options
2. **Settings → Developer options → enable USB debugging**
3. Plug in via USB-C
4. The phone prompts *"Allow USB debugging?"* - tap **Allow** and check *"Always allow from this computer"*
5. Verify on the PC:

```bash
adb devices
# Should list your device ID, not "unauthorized"
```

If it says *unauthorized*, redo step 4. If it doesn't appear at all, install the OEM USB driver (Samsung / Google / etc.) on your PC.

---

## Running the app

### Debug build (fastest iteration)

```bash
cd mobile
flutter run                          # picks the first connected device
flutter run -d <device-id>           # if multiple devices are connected
```

Debug builds are unsigned, slower, and have hot-reload. Press `r` in the terminal to hot-reload after a code change, `R` to hot-restart.

### Release build (production-equivalent)

The app builds in two **distribution flavors** (added in #178 for the Play Store): `github` (the sideloaded APK for GitHub Releases + KIAUH - the **early-access channel** - which keeps the in-app self-updater) and `play` (the App Bundle for Google Play - the **stable channel**, live in production since v0.9.57 - which ships **without** the self-updater or `REQUEST_INSTALL_PACKAGES`). Because flavors now exist, always pass `--flavor`. On the current toolchain a bare `flutter build apk --release` does not error: Gradle builds **both** flavors (≈14 min), then Flutter reports "Gradle build failed to produce an .apk file" because it looks for an unflavored `app-release.apk` - the flavored APKs are sitting in `build/app/outputs/flutter-apk/` regardless. Details in [`docs/design/play-flavor-plan.md`](docs/design/play-flavor-plan.md).

```bash
cd mobile
# Sideload / dev APK (self-updating) - this is what you install on a device
flutter build apk --release --flavor github --dart-define=MOONGATE_CHANNEL=github
# Output: build/app/outputs/flutter-apk/app-github-release.apk
adb install -r build/app/outputs/flutter-apk/app-github-release.apk
# Prove it was an in-place update (nothing wiped): firstInstallTime must be unchanged
adb shell dumpsys package com.moongate.app.moongate | grep -E 'versionCode|firstInstallTime|lastUpdateTime'

# Play App Bundle (no self-updater) - for local inspection only, never sideload it
flutter build appbundle --release --flavor play --dart-define=MOONGATE_CHANNEL=play
# Output: build/app/outputs/bundle/playRelease/app-play-release.aab
```

> **The `.aab` that goes to the Play Console must come from the CI artifact, never a local build.** Run the *Build Android APK* workflow (Actions tab; `workflow_dispatch`) and download the `Moongate-play-aab` artifact. The optional `play_build_number` input stamps a different `versionCode` onto the `.aab` only - needed when Play has already consumed a number and the same code must be re-uploaded (Play accepts each `versionCode` exactly once). Before uploading, verify the bundle: `versionCode` in `base/manifest/AndroidManifest.xml`, a feature string from the release inside `base/lib/arm64-v8a/libapp.so` (search UTF-8 **and** UTF-16-LE - Dart stores non-ASCII strings two-byte), and the signing cert SHA-256. A local build from a stale checkout once shipped old code under a new version number; the version label proves nothing about the code inside.

Release builds are signed with the keystore configured in `mobile/android/key.properties` if present, otherwise they fall back to the debug key. Both flavors share the same key and `applicationId`, so a device moves between the sideload APK and the Play build in place with no wipe. CI uses GitHub Secrets - see [Release signing](#release-signing) below.

> The QR scanner only works in **release** builds with the ProGuard rules in [`mobile/android/app/proguard-rules.pro`](mobile/android/app/proguard-rules.pro). Debug builds work fine too; R8 doesn't run in debug.

### Specific entry points

The router lives in [`mobile/lib/app.dart`](mobile/lib/app.dart). Known routes:

| Route | Screen |
|---|---|
| `/splash` | App launch animation (2 s) |
| `/dashboard` | Printer grid (the home screen after splash) |
| `/pair` | Add-a-printer flow with QR scanner |
| `/printer/:id` | Embedded Mainsail / Fluidd WebView for one printer |
| `/settings` | Sign-out page |
| `/theme/custom` | Custom-theme colour editor |

---

## Where the code lives

```
mobile/lib/
├── main.dart                   # Bootstraps providers, loads persisted state, runApp()
├── app.dart                    # MoongateApp + GoRouter + theme builders
├── features/
│   ├── auth/pairing_screen.dart        # /pair - QR scanner + manual code entry
│   ├── dashboard/dashboard_screen.dart # /dashboard - tile grid or single-printer view + drawer (incl. About section)
│   ├── dashboard/printer_tile.dart     # One card on the dashboard (+ the public Tile* pieces the single view reuses)
│   ├── dashboard/single_printer_view.dart # Single-printer dashboard: one printer full screen (v0.9.69)
│   ├── dashboard/chamber_picker_overlay.dart # Hold the chamber chip: pick which sensor the reading uses, saved per printer (v0.9.77)
│   ├── dashboard/queue_overlay.dart    # Print queue sheet on Moonraker's job_queue + the tile's Up next line (v0.9.78)
│   ├── onboarding/dashboard_style_prompt.dart # First-run "One printer or several?" question (v0.9.69)
│   ├── printer/printer_screen.dart     # /printer/:id - WebView with cookie/Bearer auth
│   ├── printer/gcode_viewer_screen.dart # G-code layer viewer: 3D orbit, five Orca-coloured heat maps (v0.9.72) + Colour view with a per-tool key and the "Opens with" menu (v0.9.73)
│   ├── printer/gcode_save.dart         # "Save to phone": whole-file download with progress -> system save-as sheet (v0.9.74)
│   ├── settings/settings_screen.dart   # /settings
│   ├── settings/custom_theme_screen.dart # /theme/custom - colour editor
│   └── splash/splash_screen.dart
├── models/
│   ├── printer_config.dart             # PrinterConfig (persisted) + PrinterStatus (live)
│   ├── heat_alerts.dart                # pure heat-soak alert rules: HeatSoakArm + evaluateHeatSoak (v0.9.67)
│   └── temp_watch.dart                 # printer-side temperature watches: /status parsing, tile line, arm payloads (v0.9.68)
├── providers/                          # Riverpod NotifierProviders
│   ├── settings_provider.dart          # AppThemeMode, app font (kAppFonts), font scale, grid cols, rotation
│   ├── custom_theme_provider.dart      # 5 user-picked colours
│   ├── update_provider.dart            # GitHub release check
│   └── version_provider.dart           # PackageInfo
└── services/                           # No UI; all I/O lives here
    ├── supabase_service.dart           # Cloud middleman - anonymous sign-in, claim/release printer, list-my-printers
    ├── printer_access_cache.dart       # In-memory cache of {tunnel_url, access_token} per printer
    ├── printer_registry.dart           # Persistent printer list + LAN URL / webcam / UI-type updaters
    ├── printer_status_service.dart     # Per-tile 4 s poll loop, LAN-first with reachability probe
    ├── printer_liveness_service.dart    # Realtime + RLS-scoped read of last_seen; gates polling of offline printers (v0.9.16)
    ├── printer_webview_cache.dart       # Keeps each printer's WebView warm; pre-warms all at startup (v0.9.8 / v0.9.15)
    ├── gcode_layer_parser.dart         # stored G-code -> layers of segments with fan/temp/speed/flow/tool + layer time, tool colours from the slicer header; isolate-safe (v0.9.72, tools v0.9.73)
    ├── gcode_cache.dart                # on-disk copy of viewed / saved G-code files in the app cache folder, ~300 MB LRU + the last parsed model (v0.9.76)
    ├── webview_link_policy.dart        # Which links the printer WebView keeps and which open outside it (v0.9.70)
    ├── print_control_service.dart      # pause/resume/cancel/firmware_restart/emergency_stop + temp-watch arm/cancel, file temps (v0.9.68)
    ├── heatsoak_timers.dart            # per-printer heat-soak arms; the notification isolate judges + fires them (v0.9.67)
    ├── print_progress.dart             # shared Mainsail-matching (file-relative) progress calc (v0.9.17)
    ├── ota_installer.dart              # in-app updater: download APK + launch installer (v0.9.17)
    ├── update_service.dart             # /APK/latest_version.json poll
    ├── lan_discovery_service.dart      # mDNS browse for _moongate._tcp Pis (v0.5)
    ├── printer_status_registry.dart    # last live status + LAN-poll outcome per printer
    └── diagnostics_service.dart        # builds the bug-report payload (app/device/network/printers)
```

> **v0.6.3 services & deps.** `supabase_service.dart` also handles backup **restore grants** (`createRestoreGrant` / `redeemRestoreGrant`) and **bug reports** (`submitFeedback`); the report UI is `features/dashboard/feedback_sheet.dart`, reachable from the drawer **and** the pairing screen. New dependency: **`device_info_plus`** (device model + Android version for reports). To read submitted reports without the Supabase dashboard, POST to the **`read-feedback`** Edge Function with header `x-moongate-debug: <MOONGATE_DEBUG_KEY>` - the secret is a Supabase function secret (`supabase secrets set/unset`), never in the repo.

> **v0.6.4-v0.6.5.** `diagnostics_service.dart` now also captures the Pi's **plugin version** (from the `/status` reply, where `moongate_standalone.py` reports `MOONGATE_PLUGIN_VERSION`) and the **remote/tunnel** connection result, not just the LAN outcome. v0.6.5 adds the first-run **"How pairing works"** onboarding - `_maybeShowPairingHelp()` / `_showPairingHelp()` in `dashboard_screen.dart`, shown once on cold launch (a persisted "Don't show again" flag suppresses it) and always reachable from the drawer's **How pairing works** item.

> **v0.9.15-v0.9.16 services & deps.** `printer_webview_cache.dart` gained a **pre-warm** path that loads every printer's WebView in the background at startup (so the first open is instant), and a new `printer_liveness_service.dart` tracks each printer's online/offline state via **Supabase Realtime** on the `printers` table (plus a periodic RLS-scoped read) so the dashboard and the notification service stop requesting access for switched-off printers. A new `onMobileDataProvider` in `providers/settings_provider.dart` drives the connectivity-aware camera feed rate. New dependency: **`connectivity_plus`** (Wi-Fi vs. mobile-data detection). The liveness feature relies on the Realtime migration below being applied to the Supabase project.

> **v0.9.17 services & native.** Two new app services: `print_progress.dart` - a pure helper that computes Mainsail-matching *file position (relative)* progress, now the **single source** for both the dashboard tile and the print notification (they used to disagree) - and `ota_installer.dart`, the in-app updater that streams the release APK to the cache dir with progress then triggers the system installer. The updater adds a native `MethodChannel` (`com.moongate.app/install`) in `MainActivity.kt`, a `FileProvider` + `res/xml/file_paths.xml`, and the `REQUEST_INSTALL_PACKAGES` permission (all now in the **`github` flavor** manifest and gated by `BuildConfig.SELF_UPDATE`, so the Play flavor omits them - #178); it reuses the existing `http` / `path_provider` / `permission_handler` deps (no new package). The post-emergency-stop restart button keys off a new `klippyShutdown` flag on `PrinterStatus`, parsed from Moonraker's `webhooks.state`. Unit test: `mobile/test/print_progress_test.dart`.

For a guided tour of how these pieces fit together, see [ARCHITECTURE.md](ARCHITECTURE.md).

---

## Running the Pi-side services

For mobile-only work you don't need a Pi - `flutter run` against any printer that already has v0.4 installed is enough.

If you're modifying the Pi side, there are now two parts to be aware of:

| File | What it is | How to reload after changes |
|---|---|---|
| `klipper-plugin/moongate_standalone.py` | The Moonraker plugin (pairing, status aggregation, control, heartbeat) | `sudo systemctl restart moonraker` |
| `klipper-plugin/moongate_authproxy.py` | The auth proxy that gates every tunnel-side request | `sudo systemctl restart moongate-authproxy` |

Both ship from the same repo and are deployed by `install.sh`:

```bash
# On your Pi
git clone https://github.com/PEEKYPAUL/Moongate.git
cd Moongate/klipper-plugin
./install.sh
```

The plugin reaches Moonraker as a **symlink**, `~/moonraker/moonraker/components/moongate.py` → `klipper-plugin/moongate_standalone.py`, so a `git pull` of the clone is the update. Moonraker's update manager runs `git status` in its own checkout and would flag that link as an untracked source file in the Software Update panel, so the link's path goes on Moonraker's per-clone ignore list, `.git/info/exclude` (never committed, survives Moonraker's own updates and its hard-recovery `git clean`): `install.sh` writes it at install time and, since plugin 0.6.28, the plugin writes it again at every component load; `uninstall.sh` removes it.

> **Moonraker never executes `update.sh`.** The `install_script` option the plugin is registered under in `[update_manager moongate]` is deprecated and only scanned for `PKGLIST` lines (Moonraker's docs: "Moonraker will not run this script"), so a panel or badge update is a `git pull` plus a Moonraker restart and nothing more. Found 25/09/2026: every migration `update.sh` carried since 0.6.14 had only ever run where someone re-ran the installer or invoked it by hand. Hence `_post_update_chores()` in `moongate_standalone.py`: the chores that need no root (the exclude line, the `moongate-tunnel` entry in `moonraker.asvc` that the tunnel watchdog depends on, a fresh copy of `moongate-pair.html` in the web roots, and the macro blocks via `_refresh_macros_cfg()`) run inside the plugin at load, idempotently, on every update path. `update.sh` stays the manual tool for the one chore that needs root, the pre-0.6.14 authproxy journal migration. Anything new that must follow an update goes in the plugin, not in the script. See [TROUBLESHOOTING.md](TROUBLESHOOTING.md#software-update-panel-says-moonraker-has-untracked-source-files-moongatepy).

To develop against the cloud-free path (v0.9.51's Direct mode), install the box in **LAN-only mode** instead - no cloudflared, no auth proxy, no clock check, and the plugin's `lan_only` config flag set so `/status` + `/control` are token-free on the LAN. Run interactively with no flag, the installer asks which mode you want (the default keeps an already-installed box's current mode, so an idle Enter never converts anything); to preselect and skip the question:

```bash
./install.sh --lan-only          # or: MOONGATE_LAN_ONLY=1 ./install.sh
MOONGATE_LAN_ONLY=0 ./install.sh # preselect cloud - converts a LAN-only box back to tunnel mode
```

Converging an existing tunnel-mode box to LAN-only also retires the running tunnel + proxy units and clears the stale tunnel-URL logs.

Iteration loop:

```bash
# Edit on your dev machine
nano klipper-plugin/moongate_standalone.py        # or moongate_authproxy.py
git push

# On the Pi
cd ~/moongate && git pull
sudo systemctl restart moonraker                  # if you touched the plugin
sudo systemctl restart moongate-authproxy         # if you touched the proxy

# Tail the relevant log
journalctl -u moonraker -f | grep -i moongate
journalctl -u moongate-authproxy -f
```

To uninstall completely:

```bash
./klipper-plugin/uninstall.sh
```

The uninstaller restores the original `moonraker.conf` from the backup it took during install, removes both systemd units, and wipes `~/.config/moongate/`.

---

## Debugging tips

### App-side: logcat filters

Once an APK is installed and you've reproduced an issue:

```bash
# Get the running app's process ID
PID=$(adb shell pidof com.moongate.app.moongate)

# Stream logs from only the app process
adb logcat --pid=$PID

# Or filter by tag - Moongate uses the "MOONGATE" tag for its own dev.log() calls
adb logcat -s MOONGATE

# Camera issues
adb logcat | grep -iE "(MobileScanner|CameraX|camera|bindToLifecycle)"
```

For non-fatal Flutter errors (red overlay in debug, console in release):

```bash
flutter logs                          # picks the connected device
```

### Plugin-side: Moonraker logs

```bash
journalctl -u moonraker -f
# Or directly:
tail -f ~/printer_data/logs/moonraker.log
```

The plugin logs under the `moonraker.moongate` logger - its messages are prefixed with `moongate` in the log.

### Auth-proxy logs (v0.4+)

```bash
journalctl -u moongate-authproxy -f
```

The proxy is quiet on the happy path (the per-request access lines were removed in plugin 0.6.14 - they were filling `/run`). Token rejections log at DEBUG - set `MG_LOG_LEVEL=DEBUG` in the unit to see per-request verdicts and distinguish "the token is bad" from "the path doesn't match anything". Since 0.6.21 any unexpected internal error writes a full traceback to the journal before answering a terse 500, so an empty journal means healthy and a traceback names the failing hop.

### Tunnel-side: cloudflared logs

```bash
journalctl -u moongate-tunnel -f
# Plus the captured stdout (this is where the tunnel URL appears):
tail -f /run/moongate-tunnel.log
```

---

## Release signing

For local *debug* and *unsigned release* builds nothing is needed.

For builds that are install-compatible with the official APKs (same signing key), you need both files in `mobile/android/`:

```
mobile/android/key.properties       # path/passwords; gitignored
mobile/android/app/moongate-release.jks   # keystore; gitignored
```

The CI pipeline ([`.github/workflows/build-android.yml`](.github/workflows/build-android.yml)) reconstructs both from GitHub Secrets on every push and signs the APK consistently. Locally-built release APKs use the debug key by default, which means installing them over a CI-signed APK requires uninstalling first (signature mismatch). This is a one-time inconvenience while iterating; CI-signed builds always replace cleanly.

---

## CI / CD

`.github/workflows/build-android.yml` runs on every push to `master`:

1. Sets up Flutter stable + JDK 17
2. Decodes the keystore from Secrets and writes `key.properties`
3. `flutter build apk --release --flavor github --dart-define=MOONGATE_CHANNEL=github` (the sideload APK) **and** `flutter build appbundle --release --flavor play --dart-define=MOONGATE_CHANNEL=play` (the Play `.aab`, kept as the `Moongate-play-aab` workflow artifact for a manual Play upload)
4. Publishes the signed github APK as a **GitHub Release asset** (`Moongate-vX.Y.Z.apk`) - it is **not** committed to the repo (the ~73 MB binary tripped GitHub's 50 MB push warning on every push)
5. Generates a fresh `APK/latest_version.json` whose `apk_url` points at that Release asset, for the in-app update banner
6. Commits **only the manifest** with `[skip ci]` and pushes back to `master`

So **the only thing you commit by hand is code + screenshots + docs**. The release APK lands as a Release asset 2-3 minutes after each push; the in-app updater downloads from there.

> **Merging a non-release CODE PR? Quiet-merge it with `[skip ci]`.** The build above runs on *every* push to `master` and rebuilds the APK for whatever `version:` is in `mobile/pubspec.yaml`. Merging a PR that **doesn't** bump the version therefore rebuilds the *current* release and **clobbers its existing Release asset** (`gh release upload --clobber`) - silently replacing the published APK with a fresh build (and the workflow commits a `latest_version.json` refresh - its `published_at` bumps - even when the build number hasn't changed, so the slip is visible in the history). For deps / config / Pi-side-only PRs, put `[skip ci]` in the **merge-commit subject** so nothing rebuilds; the changes ride into the next versioned release. **Docs-only changes no longer need this**: since 2026-07-18 the build workflows carry `paths-ignore` for `docs/**`, Markdown files and `LICENSE`, so a merge confined to those never triggers a build in the first place. One trap to know: GitHub honours a literal `[skip ci]` **anywhere in a commit message** - a commit whose message merely *mentions* the token will silently skip every check on its PR. And its mirror image: for a **single-commit PR**, GitHub prefills the squash title from that commit's own message, **not** the PR title - so a token placed only in the PR title never reaches the squash commit. Either put the token in the branch commit's subject (fine when you don't need PR checks), or give the PR a second commit so the squash title inherits the PR title.

> **Put `[skip ci]` in the PR *title* too, not only the merge subject.** GitHub's squash dialog and a plain `gh pr merge --squash` take the commit title from the PR title (or, for a single-commit PR, from that commit), so a title without the marker rebuilds and clobbers the moment someone merges from the UI - plugin 0.6.26 (#309, 2026-09-08) was clobbered exactly that way an hour after the note above was re-read. **Recovery if it happens:** the original release build is still a workflow artifact for 30 days - find the release run (`gh run list --workflow build-android.yml --branch master`), `gh run download <run-id> -n Moongate-release`, verify it (`aapt2 dump badging` for versionCode/versionName, `apksigner verify --print-certs` for the release key), then `gh release upload vX.Y.Z Moongate-vX.Y.Z.apk --clobber` puts the verified file back. The manifest's `published_at` stays bumped; the next release rewrites it.

> **Dependabot PRs never carry the token in their title**, so merging one from the GitHub UI rebuilds and clobbers just the same: #342 (supabase_flutter 2.18.0, 2026-10-07) replaced the v0.9.70 asset that way, the third time in a month. Merge them through `gh pr merge N --squash --subject "<title> (#N) [skip ci]"`, and recover as above if one slips (the 26/09 artifact went back over the v0.9.70 asset on 09/10).

> **A release PR's own commit messages must not quote the token either**, not even to describe the quiet merges the release carries. GitHub honours it inside a commit *body* as well: the first push of #332 (2026-09-26) ran no checks at all because its message said "the `[skip ci]` merges since v0.9.69", and the squash dialog prefills the merge body from those messages, which would have skipped the release build on `master` too. Write "skip-CI" or "quiet merges" in prose, and merge a release with an explicit message: `gh pr merge N --squash --subject "vX.Y.Z (BUILD): title (#N)" --body "..."` rather than the UI's prefilled one.

`ci.yml` runs `flutter analyze` and `flutter test` on PRs (unit tests live in `mobile/test/`; the Pi plugin additionally has standalone stdlib-only tests under `klipper-plugin/tests/` - dependency isolation, the heartbeat pairing window, the tunnel watchdog, the MOONGATE_STATUS wording, the webcam-list contract, the self-update capability gate, the notification event matrix, and the MOONGATE_NOTIFY `last_notify` record (the macro handler is driven against a stub, no Moonraker needed) - each runnable with plain `python3` on any machine).

> **Adding a setting? Classify it for backups.** `settings_backup_completeness_test.dart` scans `lib/` for every preference key the app touches and fails CI unless each one is either on the `SettingsBackup` allow-list (it rides the user's backup file - the usual choice) or in that test's exclusion map with a written reason it must not (device-bound, transient, and so on). A new SharedPreferences key that skips both lists turns the suite red until you pick a side.

> **The Supabase backend is not part of CI.** Database migrations under [`supabase/migrations/`](supabase/migrations/) and the Edge Functions under [`supabase/functions/`](supabase/functions/) are deployed **manually** against the live project - `supabase db push` for migrations and `supabase functions deploy <name>` for functions - never by GitHub Actions. So a PR that adds a migration (e.g. `20260619120000_printers_realtime.sql`, which enabled Realtime on the `printers` table for v0.9.16) or changes a function (e.g. the access-token TTL in `_shared/accessToken.ts`) only takes effect once it's pushed to Supabase by hand. Full backend setup / deploy flow: [`supabase/README.md`](supabase/README.md). **A migration that creates a table must carry its `GRANT`s** (plus RLS and policies, as one unit): Supabase stopped granting Data API access to new `public` tables automatically on 30 October 2026, and a table without an explicit grant is unreachable by the Edge Functions and the app. `20260923120000_explicit_table_grants.sql` is the model; the verify query is in README section 3.5.

---

## Adding or updating a translation

Moongate ships nine languages, all as simple `.arb` string files - the Brazilian-Portuguese community contribution ([#183](https://github.com/PEEKYPAUL/Moongate/pull/183)/[#184](https://github.com/PEEKYPAUL/Moongate/pull/184)) is the reference example. To add a language:

1. Copy [`mobile/lib/l10n/app_en.arb`](mobile/lib/l10n/app_en.arb) to `app_<code>.arb` using a **plain base language code** (`app_pt.arb`, not `app_pt_BR.arb`) and set its `"@@locale"` to the same code. `flutter gen-l10n` refuses a country-coded file unless a base file for that language also exists, and the app stores plain codes - a regional file quietly never loads.
2. Translate every key, keeping each `{placeholder}` exactly as it appears in English. The file is strict JSON: UTF-8 without BOM, no trailing commas.
3. Add the language to `kLanguageOptions` in [`mobile/lib/features/language/language_picker.dart`](mobile/lib/features/language/language_picker.dart) - the label is the language's own native name and is deliberately the same in every locale.
4. Run `flutter gen-l10n` from `mobile/` and commit the regenerated `app_localizations*.dart` files. **Never edit the generated files by hand** - the delegate wiring inside them is exactly the part hand-edits get wrong, and the next regeneration discards them anyway.
5. `flutter analyze` should come back clean, and gen-l10n itself reports any untranslated keys per language.

## Bumping a release

1. Edit `mobile/pubspec.yaml`:

   ```yaml
   version: 0.9.X+Y     # X = semver patch, Y = monotonic build number
   ```

2. Add the version's entry to [`mobile/assets/changelog.json`](mobile/assets/changelog.json) (newest first): **one to four short bullets, plain language, what the user gets or what was fixed**. No file names, commands, protocols or toolchain notes; "needs plugin 0.6.x" is the one internal detail worth a mention. This single file feeds the in-app "What's new" dialog (bundled), the update banner's "What's new" overlay (fetched from `master`) **and** [CHANGELOG.md](CHANGELOG.md). Readers asked for this in October 2026 ("a wall of text, nobody reads it"); the full technical account goes in the PR description instead.

3. Re-render [CHANGELOG.md](CHANGELOG.md) from the JSON (one heading per version, the same bullets, the fixed pre-release tail at the bottom) so the two never drift. Never hand-write prose rows there.

4. Commit and push to `master`. CI does the rest - versioned APK + `latest_version.json` update + commit-back happens automatically.

In-app, users running an older version will see the update banner appear within ~30 s of the next launch.

> Feature branches (anything other than `master`) **do not** trigger CI APK builds. That's intentional - unreleased work doesn't get pulled into the in-app updater.

### Plugin version (Pi side)

The version shown in Mainsail's **Software Update** panel is derived from the repo's git **tags** - the same `vX.Y.Z` release tags CI creates - not from a number the plugin sets, so the panel tracks the project release with nothing extra to bump. The plugin *does* define a `MOONGATE_PLUGIN_VERSION` constant, reported in its `/status` reply: it pins down the exact plugin build in **bug-report diagnostics**, and since v0.9.50 it also drives the dashboard's **plugin-update badge** - the app compares it against `kCurrentPluginVersion` in [`mobile/lib/config/plugin_version.dart`](mobile/lib/config/plugin_version.dart).

> **Rule: bump `kCurrentPluginVersion` in the SAME PR as any `MOONGATE_PLUGIN_VERSION` bump.** The two travel together in a release; if they drift, the app either nags about a plugin that doesn't exist yet or fails to nag about one that does.

For this to work the Pi's clone must carry tags. `install.sh` uses a **blobless** clone (`git clone --filter=blob:none`) - full ref/tag history, but without the large historical APK blobs - and converts any old shallow (`--depth=1`) clone on re-run. A shallow clone shows `v0.0.0-…-inferred` instead; see [TROUBLESHOOTING.md](TROUBLESHOOTING.md#software-update-panel-shows-an-inferred-version-for-moongate).

> The CI "keep last 3 releases" prune deletes old tags, but the most recent tag is always an ancestor of `master` HEAD, so version detection on an up-to-date Pi always resolves.

---

## Coding conventions

### Updating Klipper completion metadata

The config editor and console catalogs are generated from an explicit Klipper
commit using only Python's standard library:

```bash
python3 tool/generate_klipper_catalog.py --ref <klipper-commit-sha>
cd mobile
flutter test test/klipper_schema_test.dart
```

Review the generated diff with the code change that updates the pinned
revision. Do not hand-edit the generated JSON.

| Topic | Rule |
|---|---|
| **Dart lints** | `flutter_lints` (see `mobile/analysis_options.yaml`). `flutter analyze` must be clean before push |
| **Folder structure** | Feature-first: `lib/features/<area>/<screen>.dart`. Shared cross-feature code goes in `lib/services/` or `lib/providers/` |
| **State management** | Riverpod `NotifierProvider`. Avoid `StatefulWidget` for app-wide state |
| **Colour API** | `withValues(alpha: 0.5)`, **not** the deprecated `withOpacity()` |
| **Bottom sheets** | Every `showModalBottomSheet` body wraps in `Padding(bottom: MediaQuery.viewInsetsOf(context).bottom)` (keyboard) **and** `SafeArea(top: false)` (navigation / gesture bars), or adds `MediaQuery.paddingOf(context).bottom` to its scroll padding. `useSafeArea: true` on the sheet protects only the top and sides; the v0.9.64 control panel relied on it and its bottom row sat behind 3-button navigation (fixed v0.9.65). Reference implementations: `preheat_overlay.dart`, `console_overlay.dart`, `file_system_overlay.dart`, `control_panel_overlay.dart`. Device-check every new sheet with the nav bar in 3-button mode, in landscape, and with the keyboard up |
| **Tutorial anchors** | Mount the `TutorialAnchors` GlobalKeys only while a tour runs (`tutorialControllerProvider` active), never permanently. A GlobalKey carries its subtree's State to wherever it mounts next, so an always-anchored widget hands its camera block (the last frame, and power / light buttons bound to ITS printer) to the next printer that takes the anchor - it bled between printers on the first v0.9.69 single-printer build and was latent on the first dashboard tile whenever auto-arrange changed which printer sat first |
| **Button grids** | Rows of labelled buttons (macro chips, tool buttons) are measured, not guessed: a `LayoutBuilder` plus `TextPainter` at `MediaQuery.textScalerOf(context)` decides whether a label fits its cell, with a chrome allowance for the button's padding and icon (`AdaptiveToolButton`, the control panel's macro grid). A free-flowing `Wrap` of intrinsic-width buttons rags the right edge |
| **Python** | PEP 8, type hints on public functions, zero runtime deps beyond what Moonraker already pulls in |
| **Plugin lint** | CI runs `ruff check klipper-plugin/` with ruff **pinned** (see `.github/workflows/ci.yml`) - new ruff releases change default rules, so unpin only together with a deliberate lint cleanup |
| **Commits** | Conventional prefixes: `feat:`, `fix:`, `docs:`, `release:`, `chore:`. Bodies wrap at ~72 cols |
| **Pushes** | Push often. CI is the source of truth for the released APK |

---

## Where to next

- [ARCHITECTURE.md](ARCHITECTURE.md) - how the pieces fit together, data flow diagrams, key design decisions
- [SECURITY.md](SECURITY.md) - auth, transport, threat model, audit references
- [docs/setup-guide.md](docs/setup-guide.md) - end-user setup walkthrough (the friendlier version of [README.md](README.md#quick-start))