Contribution Guidelines
This section is verbatim the CONTRIBUTING.md.
CONTRIBUTING.md
This project has more boilerplate than real code, and a couple of hacky, genuinely cursed corners.
A not quite short, opinionated list. Most of these exist because something broke once. The checklists at the end are only important if you need them, if you don’t want to see the bodge, don’t read them preemptively.
0. Setup
Clone with submodules. Either
git clone --recursive <url>, or if you already cloned:git submodule update --init --recursive. Half the project lives in submodules; without them nothing builds. Heads up:.flutteris the whole Flutter SDK (pinned via submodule, fetched over SSH), so the clone is big and slow and the first.flutter/bin/flutterrun bootstraps its own Dart. This is normal, go get a coffee. Or use the OS toolchain if you must, but this is at the end of a long sentence on purpose, I don’t like the idea of random versions breaking my CI build (been there done that).Always use the bundled toolchain:
.flutter/bin/flutterand.flutter/bin/dart, never your systemflutter/dart. The version is pinned for a reason, and mismatched versions cause exactly the kind of mischief you don’t want to debug.Generate code before you build anything. The Freezed / Riverpod / Drift part files (
*.freezed.dart,*.g.dart,*.drift.dart) are not checked in, so a fresh clone is full of “undefined_$SettingsState” errors until you run:.flutter/bin/dart run build_runner build --delete-conflicting-outputs(
build_runner watchif you’d rather it keep up as you go). If you touch the Drift schema you also need.flutter/bin/dart run drift_dev make-migrations.
1. Build both flavors before opening a PR
TiefPrompt ships in two flavors that differ only by
entrypoint: lib/main_foss.dart (F-Droid, direct APK) and
lib/main_freemium.dart (App Store, Play Store). Freemium
pulls in IAP packages; FOSS must build without them.
It is entirely possible to write code that builds fine for freemium but breaks FOSS, because something you imported imported something that imported an IAP-only dependency. This has happened. So build both before you push:
.flutter/bin/flutter build <target> -t lib/main_foss.dart
.flutter/bin/flutter build <target> -t lib/main_freemium.dart
(or
tools/build.sh -t <targets> -f <freedom>). A PR
that only builds one flavor is not done. (CI should catch it, but darn
me if that ever not works…)
2. Separate concerns
The layers, and what belongs in each:
lib/core/: reusable utilities and shared codelib/models/: the Drift database and its modelslib/providers/: Riverpod providers that own statelib/services/: Riverpod providers that are stateless and purely functionallib/ui/: screens and widgets
Hard rules:
- DB access does not live in the UI. Route it through a provider or service.
- Don’t reach across layers to dodge writing a provider. Write the provider.
(Yes, feature_provider lives in providers/
but is really a service. It’s a known wart, left as-is on purpose, so
don’t “fix” it as a drive-by, there be dragons.)
3. Think about what you’ve done
Before you open the PR, re-read your own diff. Not for typos, for fit. Does it match how the surrounding code already does things, or did you invent a second way? Did you add state to a widget that should have been a provider? Did you hardcode something that has a constant three files over? Most review comments on this repo are things the author would have caught by reading their own diff once.
4. Don’t branch on flavor in the UI
Widgets must never ask “am I FOSS or freemium?”. Gating goes through
the feature system: wrap things in _FeatureGate /
_FeatureGatedIconButton, or check
featuresProvider.select((s) => s.features.contains(Feature.x))
(see prompter_bottom_bar.dart and
app_settings.dart). Note that AppSetting and
its derivatives gate themselves automatically off the
Feature you give them, so for those you just need to pass
the right one. The only place flavor actually differs is the entrypoint
and its feature_provider override. Keep it that way, it’s
the whole reason the split stays maintainable. (Ignore the BS I did
early day with the button on the home screen. If you dare, fix it.)
5. State lives in providers; navigate with go_router
- Prefer providers over widget-local state so it survives rebuilds.
Use
@riverpodcodegen by default; a plainNotifieris fine only for trivial single-value toggles (seecontrolsVisibleProvider). - All navigation goes through
go_router. Don’t callNavigator.pushdirectly. - Routes go in
router_provider.dart. This may seem confusing, but it has reasons. Reasons I can’t remember. But reasons!
6. Localize user-facing strings, but stay in your lane
Every string a user sees goes through easy_localization:
context.tr("Some.Key"), with the key defined in the
translation assets. Hardcoded strings are acceptable only where no
BuildContext is available, and those must be in
English.
Only touch English and your own native language. Do not edit, “improve”, or add other languages, and do not commit AI-generated translations. A wrong translation you can’t read is worse than a missing one, leave those to people who actually speak the language.
7. Profit
When I add a Feature
A Feature is not “a Pro thing”. It’s the unit of gating
for the whole app. The point is to be able to move any capability
between free and Pro trivially. So: if you implement anything that does
something and could conceivably be gated, give it a
Feature. When in doubt, add one.
Always add a new Feature. Never reuse an
existing one because it’s “close enough” (e.g. don’t gate a new
line-height control behind Feature.fontSize). Reuse looks
cheaper today and welds two capabilities together forever, which defeats
the entire reason this enum exists. Yes, a new Feature costs you the
extra steps below. Pay them.
- Add the value to the
Featureenum incore/constants.dart. - Add it to
kAllFeatures. Add it tokFreeFeaturestoo if it should be free in the freemium build. - Gate it in the UI with
_FeatureGate/_FeatureGatedIconButton, or afeaturesProvider.select(...)check. Never check the flavor directly. (If the thing is anAppSettingor a derivative, it gates itself off theFeatureyou pass it, so just pass the right one.) - Add a
BuyProScreen.BuyRequest.<featureName>translation key. It’s rendered as Markdown on the buy screen when someone taps the locked feature, so give it an h2 header and a little description of what they’re buying.
When I add a Route
- Add a
GoRouteinrouter_provider.dart(nest it under/settingsif it’s a settings subscreen). - Create the screen widget under
lib/ui/screens/(orscreens/settings/). - Navigate to it with
context.push("/your_path"). - If it needs arguments, pass them via
extra:using a small router-extra class (seeDisabledFeatureScreenRouterExtra). - Re-run
build_runner(you editedrouter_provider.dart).