Mobile app conventions (apps/mobile)
Rules for Claude when editing anything under apps/mobile/lib. These exist
so screens stay visually consistent and reviewers don’t have to re-litigate
the same tradeoffs every PR.
Page layout
Section titled “Page layout”AppSurfaceowns horizontal page padding. Default isAppSpacing.lg(20px). Do NOT add a second horizontal padding on the immediate scroll child (ListView/SingleChildScrollView/CustomScrollView body) — that stacks withAppSurfaceand visibly narrows the page. If you need a different page padding, overrideAppSurface.padding; do not double up.- Vertical page padding belongs on the scroll child (so the scroll bar
reaches the edges), but horizontal stays on
AppSurface. - When a screen needs edge-to-edge content (e.g. full-bleed photo header),
pass
padding: EdgeInsets.zerotoAppSurfaceand apply the desired padding deeper in the tree — seediscover_results_page.dart. - Every full screen should be wrapped in
AppSurface(withtone:), not a bareScaffoldbody.
Tokens (no magic numbers in widget files)
Section titled “Tokens (no magic numbers in widget files)”Pull from lib/design_system/tokens/tokens.dart rather than hardcoding:
- Spacing:
AppSpacing.{xxs 4, xs 8, sm 12, md 16, lg 20, xl 24, xxl 32, xxxl 40}. - Color:
AppColors.*(surfaces, text on light/dark, semantic, brand). Never reach forColors.grey[500]or hex codes for app chrome — use a named token, or add one if a new shade is genuinely needed. - Type:
AppTypography.{display, h1, h2, title, body, label, caption}. Use.copyWithfor one-off variants — don’t redefine sizes from scratch. - Radius:
AppRadius.{sm 8, md 16, lg 24, xl 32, pill 999}. - Motion:
AppMotion.{fast 120ms, base 220ms, slow 360ms, standard curve}. - Elevation:
AppElevation.{card, pill}for box shadows.
Raw pixel values (14, 28, etc.) inside Positioned, BorderRadius.circular,
SizedBox(height:), etc. should be flagged unless they’re inside a leaf
widget that consciously composes a primitive (e.g. tuning a glass badge
to a specific 30px hit target). When in doubt, use a token.
Theming (light / dark — switchable)
Section titled “Theming (light / dark — switchable)”The app ships two palettes and switches at runtime:
- dark — the web-parity “liquid glass” set (bg
#0b1020, card#121a33, indigo/emerald accents). Default. - light — the original pre-parity set (cream
#f3eee2, forest green, lime#c5e04a).
How it works (don’t fight this):
AppColors.*tokens are getters, notconst— each resolves againstAppColors.brightness. So a token likeAppColors.surfaceCardis not a compile-time constant: you can’t use it inside aconstexpression. Dropconstfrom any literal that references a token (keepconston the parts that stay constant, e.g.const EdgeInsets.all(8)).accentRoseandtextOnDarkare the onlyconsttokens (same in both themes).ThemeCubit(lib/app/theme_controller.dart) owns theThemeMode(system/light/dark), persists it viashared_preferences, and exposessetMode/brightnessFor. The Profile page has the System/Light/Dark selector.app.dartrebuilds onThemeCubitchanges: it setsAppColors.brightness, rebuildsMaterialAppwithAppTheme.build(), and — because widgets readAppColorsdirectly (not viaTheme.of) — callsreassembleApplication()once so already-mounted routes repaint too.- Always use
AppColors.*for chrome (surfaces/text/borders/primary). Never hardcode a hex for app chrome — it won’t switch themes. Activity-kind colors (activityMeta) are intentionally theme-independent brand hues and stay as literals.
State management
Section titled “State management”- Use
flutter_bloc(Bloc/Cubit) — match the surrounding feature. Don’t introduce Provider, Riverpod, GetX, or Redux. - Cubits live under
features/<area>/bloc/; states under the same folder as sealed classes or equatable variants. - Repositories under
features/<area>/data/; pure DTOs/models underfeatures/<area>/domain/. Keep widgets free of fetch/cache logic.
Routing
Section titled “Routing”go_routeronly. Route names are constants inlib/core/router/route_names.dart— never hardcode a path string in a widget. Usecontext.push(...)for forward nav,context.pop()for back. Deep links go throughlib/core/deep_links/.
Images & networking
Section titled “Images & networking”- Network images use
CachedNetworkImagewith a placeholder + errorWidget fallback. PlainImage.networkis not acceptable in the app — it re-downloads on every rebuild and shows no loading state. - HTTP calls go through the repository layer, never directly from a widget.
Lists, cards, scrolls
Section titled “Lists, cards, scrolls”- Cards use
ClipRRect(borderRadius: BorderRadius.circular(28))withAppElevation.card. If you’re picking a different radius, justify it in the diff — visual consistency matters more than micro-aesthetics. - For carousels inside cards, use
PageView.builderwith a small_PageDotsindicator. Don’t pull in a carousel package for this.
Text legibility on photos
Section titled “Text legibility on photos”- Always pair text-over-image with a bottom-up dark scrim (a top-to-bottom
gradient
[0x00, 0x40, 0xD9]ish over black). Don’t rely on shadows alone — light photos will still wash text out. - Acceptable to add
shadows:on the text on top of the scrim for extra insurance, especially for the title.
What NOT to do
Section titled “What NOT to do”- Don’t introduce new packages without a clear need. Check
pubspec.yamlfor an existing alternative first. - Don’t add
print(...)statements — usedebugPrintand remove before commit, or use structured logging if a logger exists in the area. - Don’t write multi-paragraph doc comments on Flutter widgets. One short
///line is fine. Long widget classes need a structural comment at the top explaining the layout; per-method docstrings are noise. - Don’t catch-and-swallow exceptions in widgets — let the bloc/cubit
emit an error state and render an error view (see
_ErrorViewpatterns).
Backend coupling (when touching DTOs)
Section titled “Backend coupling (when touching DTOs)”When you change a model in domain/ to add fields from a backend
response, check whether the backend (apps/backend/src/.../*.service.ts)
or workers (apps/workers/src/treeper_workers/...) actually populates
that field today. PostgREST embedded resources (itinerary_images,
itinerary_sources) only arrive when the service explicitly selects
them — don’t assume they’re always present.