Skip to content

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.

  • AppSurface owns horizontal page padding. Default is AppSpacing.lg (20px). Do NOT add a second horizontal padding on the immediate scroll child (ListView/SingleChildScrollView/CustomScrollView body) — that stacks with AppSurface and visibly narrows the page. If you need a different page padding, override AppSurface.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.zero to AppSurface and apply the desired padding deeper in the tree — see discover_results_page.dart.
  • Every full screen should be wrapped in AppSurface (with tone:), not a bare Scaffold body.

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 for Colors.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 .copyWith for 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.

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, not const — each resolves against AppColors.brightness. So a token like AppColors.surfaceCard is not a compile-time constant: you can’t use it inside a const expression. Drop const from any literal that references a token (keep const on the parts that stay constant, e.g. const EdgeInsets.all(8)). accentRose and textOnDark are the only const tokens (same in both themes).
  • ThemeCubit (lib/app/theme_controller.dart) owns the ThemeMode (system/light/dark), persists it via shared_preferences, and exposes setMode / brightnessFor. The Profile page has the System/Light/Dark selector.
  • app.dart rebuilds on ThemeCubit changes: it sets AppColors.brightness, rebuilds MaterialApp with AppTheme.build(), and — because widgets read AppColors directly (not via Theme.of) — calls reassembleApplication() 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.
  • 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 under features/<area>/domain/. Keep widgets free of fetch/cache logic.
  • go_router only. Route names are constants in lib/core/router/route_names.dart — never hardcode a path string in a widget. Use context.push(...) for forward nav, context.pop() for back. Deep links go through lib/core/deep_links/.
  • Network images use CachedNetworkImage with a placeholder + errorWidget fallback. Plain Image.network is 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.
  • Cards use ClipRRect(borderRadius: BorderRadius.circular(28)) with AppElevation.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.builder with a small _PageDots indicator. Don’t pull in a carousel package for this.
  • 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.
  • Don’t introduce new packages without a clear need. Check pubspec.yaml for an existing alternative first.
  • Don’t add print(...) statements — use debugPrint and 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 _ErrorView patterns).

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.