From f5b164c59dad43aad2e5167ad6d5a937c1a12d72 Mon Sep 17 00:00:00 2001 From: Amaury Ricardo Date: Tue, 22 Sep 2026 16:55:33 -0300 Subject: [PATCH] docs: add CLAUDE.md and architecture/development docs Document the template as implemented, for developers and AI coding agents: - CLAUDE.md: repo identity, architecture summary, repo map, verified commands, change rules, generated files, definition of done - docs/architecture: overview, module guide (dependency rules), known issues register - docs/development: feature guide, testing guide, bootstrap customization - README: link the docs No code or config changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 173 +++++++++++++++++ README.md | 8 + docs/architecture/known-issues.md | 91 +++++++++ docs/architecture/modules.md | 105 ++++++++++ docs/architecture/overview.md | 202 ++++++++++++++++++++ docs/development/bootstrap-customization.md | 85 ++++++++ docs/development/feature-guide.md | 98 ++++++++++ docs/development/testing.md | 59 ++++++ 8 files changed, 821 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/architecture/known-issues.md create mode 100644 docs/architecture/modules.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/development/bootstrap-customization.md create mode 100644 docs/development/feature-guide.md create mode 100644 docs/development/testing.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f4498c7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,173 @@ +# CLAUDE.md — Flutter Base (Rootstrap bootstrap template) + +## What this repository is + +A **reusable company template**, not a product. Teams clone it to start new Flutter apps. Every change +here is inherited by future projects, so optimize for reuse and clear boundaries, and keep +product-specific assumptions out of the base modules. + +If you are working in a **project cloned from this template**, the same rules apply, but the example +features (auth, onboarding, home) and the placeholders are yours to replace. +See [docs/development/bootstrap-customization.md](docs/development/bootstrap-customization.md). + +## Architecture in one screen + +A Dart pub workspace, managed with Melos 7, with one Flutter app and three local path packages, layered like this: + +``` +app ──► domain ──► common + │ ╲ ▲ + │ ╲──► data ───────┘ data ──► domain (implements its interfaces) + └──────────────────────► common +``` + +| Package | Role | Holds | +|---|---|---| +| `app/` | Presentation + composition root | Pages, widgets, go_router routes, theme, l10n, flavor entrypoints, DI bootstrap | +| `modules/domain/` | Business logic | Cubits and states, services, repository **interfaces**, models, `EnvConfig` | +| `modules/data/` | Data access | Repository **implementations**, Dio `NetworkConfig`, interceptors, `Preferences` (shared_preferences) | +| `modules/common/` | Shared utilities with no feature knowledge | `ResultType`, `Resource`, `Failure`, platform/permission abstractions, analytics interface, validators | + +- **State management:** `flutter_bloc` Cubits, which live in `domain` (not in `app`). Async screens use + `BaseCubit`, which emits `Resource` (`RLoading`/`RSuccess`/`RError`). +- **Result flow:** repository returns `Future>` (`TSuccess`/`TError`) → service → cubit → `Resource` → widget via `BlocBuilder`. +- **DI:** GetIt. Each package exposes `XInit.initialize(getIt)`. The order in `app/lib/main/init.dart` is Common → Data → Domain. +- **Navigation:** go_router, with the `Routes` enum and `Routers.appRouter` in `app/lib/presentation/navigation/routers.dart`. Auth gating is done with route `redirect` and a root `BlocListener`. +- **Networking:** a single Dio instance from `NetworkConfig.provideDio`, with `AuthTokenInterceptor`. The base URL comes from `EnvConfig.apiUrl`. +- **Persistence:** the `Preferences` interface over `SharedPreferences` (in `data`). +- **Errors:** the sealed `Failure` hierarchy plus the `DioException.toFailure()` mapper (in `common`). +- **Flavors:** `dev` / `qa` / `prod` entrypoints + `flutter_dotenv`. Read [docs/architecture/overview.md § Environments](docs/architecture/overview.md#environments-and-flavors) before touching env handling. It has known inconsistencies. + +Deeper docs: +- [docs/architecture/overview.md](docs/architecture/overview.md): layers, data flow, DI, navigation, env +- [docs/architecture/modules.md](docs/architecture/modules.md): **module boundaries, dependency rules, creating a module** +- [docs/development/feature-guide.md](docs/development/feature-guide.md): how to add a feature end to end +- [docs/development/testing.md](docs/development/testing.md): testing strategy and commands +- [docs/development/bootstrap-customization.md](docs/development/bootstrap-customization.md): what to change in a new project +- [docs/architecture/known-issues.md](docs/architecture/known-issues.md): verified defects and tech debt. **Read it before "fixing" something that looks wrong.** + +## Repository map + +``` +app/ Flutter application (package name: app) + lib/main.dart prod entrypoint + lib/main/env/ main_dev.dart, main_qa.dart, env_config.dart (Flavor, FlavorConfig, Environment) + lib/main/init.dart composition root: dotenv load, GetIt registration, runApp + lib/main/app.dart MaterialApp.router, global BlocProviders, deep-link initial location + lib/presentation/ + navigation/ Routes enum + GoRouter tree + ui/pages/// screens (auth/login, auth/sign_up, onboarding, main/home, splash) + ui/components/ reusable design-system widgets (PrimaryButton) + ui/custom/ app-wide custom widgets (DebugBanner, Cookies, FailureWidget, ...) + ui/base/ TrackedPage + RouteObserver (analytics scaffolding) + themes/ LocalTheme, AppThemes, light/dark palettes (Material 3 tonal) + resources/ Dimen, Images (part files of resources.dart), locale/*.arb + generated/ + env/ dotenv files bundled as assets (.dev, .env.example) + test/ app tests + android/ ios/ web/ linux/ platform projects +modules/domain/lib/ bloc/, services/, repositories/ (interfaces), models/, env/, init.dart +modules/data/lib/ repositories/ (impls), network/, preferences/, data_sources/ (placeholders), init.dart +modules/common/lib/ core/, devices/, analytics/, ui/, validators/, init.dart +pubspec.yaml (root) pub workspace root (`workspace:` members) + the `melos:` scripts; one shared pubspec.lock +.fvmrc pinned Flutter SDK version (FVM). CI reads it too +.github/workflows/ sonar-qube-scann.yml: analyze + tests + coverage/SonarQube on every PR and push to main +coverage/full_coverage.py multi-package LCOV merge + SonarQube upload +sonar-project.properties SonarQube config (placeholders) +addModule.py pulls a module from rootstrap/flutter-modules (see known-issues) +``` + +## Commands + +Prerequisites: FVM (`dart pub global activate fvm`, then `fvm install` installs the Flutter version pinned in +`.fvmrc`, 3.41.3) and Melos 7 (`dart pub global activate melos`). Packages require Dart `>=3.6.0` and Flutter `>=3.41.0`. +Run Flutter through FVM (`fvm flutter …`) so you use the pinned SDK. The commands below say `flutter` for brevity. + +| Task | Command (from repo root unless noted) | +|---|---| +| Install deps for the whole workspace | `melos bootstrap` | +| Check toolchain | `melos doctor` | +| Static analysis (CI gate) | `melos run analyze` (runs `dart analyze . --fatal-infos` per package) | +| Format check | `melos run format` (runs `dart format --set-exit-if-changed .` per package, and fails on unformatted code) | +| Analyze + format | `melos run lint:all` | +| Regenerate l10n after editing `.arb` | `cd app && dart run intl_utils:generate` | +| build_runner | `melos run pub:runner`. No generators are configured today (see known-issues #12). | +| Run (dev) | `cd app && flutter run -t lib/main/env/main_dev.dart --dart-define-from-file=env/.dev` | +| Run web | `melos run run:web` (uses the **prod** entrypoint `lib/main.dart` with `env/.dev`) | +| Test (CI gate) | `melos exec --dir-exists=test -- flutter test` (runs every package that has a `test/` directory) | +| Coverage + Sonar | `python3 coverage/full_coverage.py --dry-run` (drop `--dry-run` to execute; `--ci` for non-interactive) | +| Build Android | `cd app && flutter build appbundle -t lib/main.dart --dart-define-from-file=` | +| Build iOS | `cd app && flutter build ipa --release -t lib/main.dart --dart-define-from-file=` | + +Flavors: iOS has `Dev`/`QA`/`Runner` schemes (`--flavor dev|qa` works on iOS). Android has **no** +`productFlavors`, so select the environment with `-t ` only. Details are in overview.md. + +## Rules for changing this repository + +1. **Respect dependency direction.** `common` depends on no workspace package. `domain` depends only on + `common`. `data` depends on `domain` + `common`. `app` depends on all three. Never import `app` from a + module, never import `data` from `domain`, and in `app` import `package:data/...` **only** from + `lib/main/init.dart` (DI). Full rules are in [modules.md](docs/architecture/modules.md). +2. **Interfaces in `domain`, implementations in `data`.** Presentation talks to cubits and services, not to + repositories or Dio. (`ui/custom/cookies.dart` violates this. It is a known exception, don't copy it.) +3. **Follow the established result pipeline.** Repositories return `ResultType`. Cubits extend + `BaseCubit` and use `onResult(...)` or a `switch` on `TSuccess`/`TError`. Do **not** chain + `mapSuccess`/`mapError` for side effects: `mapError` never calls its callback (known-issues #1). +4. **Register everything in the owning package's `init.dart`.** Global cubits are singletons in + `DomainInit`. Screen-scoped cubits should be created with `BlocProvider(create: ...)` at the page. +5. **Reuse before adding.** Check `common/core`, `BaseCubit`, `ListBlocState`, `PrimaryButton`, + `FailureWidget`, `Dimen`, `context.colors`/`context.theme`, and `FormValidator` first. +6. **User-facing strings go in `app/lib/presentation/resources/locale/intl_*.arb`** (both `en` and `es`), + then regenerate. Never hand-edit `locale/generated/`. +7. **Spacing and sizes come from `Dimen`**, and colors come from the theme (`Theme.of(context).colorScheme` + or `context.colors`). Don't hardcode them in new code. +8. **Keep the base generic.** No product names, endpoints, or business rules in `common`, `domain/bloc/base_cubit.dart`, + `data/network/`, or the theme scaffolding. Example features stay clearly examples. +9. **Don't introduce a second pattern** (Riverpod, Provider-only state, another HTTP client, another DI + container, freezed/json_serializable) without an explicit architecture decision. +10. **Don't edit generated or platform-generated files** (see below). +11. **Behavior changes need tests.** See [testing.md](docs/development/testing.md). There are no tests + yet, so create the package's `test/` directory as the guide describes rather than skipping. CI picks it up automatically. +12. **Report pre-existing problems; don't silently fix them** in unrelated changes. Record them in + known-issues.md instead. + +### Generated / do-not-edit files +- `app/lib/presentation/resources/locale/generated/**`: intl_utils output. Edit the `.arb` files and regenerate. +- `**/generated_plugin_registrant.*`, `app/linux/flutter/generated_*`, `ios/Flutter/Generated.xcconfig`: Flutter tool output. +- `pubspec.lock` (a single workspace lock at the root, git-ignored), `.dart_tool/`, `.fvm/`, `build/`, `coverage/lcov*.info`. +- If you add build_runner generators: `*.g.dart`, `*.freezed.dart`, `*.mocks.dart` (already excluded in Sonar and coverage). + +## Before implementing + +1. Identify the affected package(s) and layer(s), using the table above. +2. Read the matching section in `docs/`, and the package's `lib/**/README.md` if present. +3. Find the closest existing example. **Canonical references:** auth + (`AuthRepository` → `AuthRepositoryImpl` → `AuthService` → `AuthCubit` → `login_form.dart`) for a + request/response flow, and `AppCubit` + `CommonRepository` for persisted settings. +4. Check dependency direction for every new import. +5. List the existing abstractions you will reuse. +6. Decide the tests you'll add (cubit, repository, widget). + +## Definition of done + +- [ ] `melos run format` passes (run `dart format .` in the package to fix) +- [ ] `melos run analyze` passes (it is `--fatal-infos`, so infos fail too) +- [ ] `melos exec --dir-exists=test -- flutter test` passes +- [ ] `.arb` edited in all locales and `dart run intl_utils:generate` output committed +- [ ] New code registered in the right `init.dart`; no forbidden imports (the rules in § Rules and modules.md) +- [ ] App still launches on the dev entrypoint; `flutter build` succeeds for platforms touched +- [ ] Docs updated if you changed a boundary, command, extension point, or env handling +- [ ] PR follows `.github/pull_request_template.md` (description, issue link, preview) + +CI (`.github/workflows/sonar-qube-scann.yml`) is meant to run analyze, the tests and coverage/SonarQube on +every PR, but it currently fails before any of them run (missing `SSH_PRIVATE_KEY` secret). It also has no +format or build step. Run the whole checklist locally (known-issues #3). + +## Delivery system + +This repository can be worked on with Rootshift. None of its files are kept in this repository. + +## Other agent instruction files + +`.cursor/rules/*.mdc` and `.github/instructions/*.instructions.md` predate this file and partly +contradict the code (naming, global cubits, helpers that don't exist). **Where they conflict, this +file and `docs/` win**, because they describe the code as it is. diff --git a/README.md b/README.md index b59e99c..8709012 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ Flutter base is a boilerplate project created by Rootstrap for new projects usin objective is helping any new projects jump start into feature development by providing a handful of functionalities. +## Documentation + +- [CLAUDE.md](CLAUDE.md): the entry point for AI agents and a quick reference for developers (architecture, commands, rules, definition of done) +- [Architecture overview](docs/architecture/overview.md) · [Module guide](docs/architecture/modules.md) · [Known issues](docs/architecture/known-issues.md) +- [Feature development](docs/development/feature-guide.md) · [Testing](docs/development/testing.md) · [Bootstrap customization](docs/development/bootstrap-customization.md) + +Where this README and `docs/` disagree, `docs/` reflects the current code (see known-issues #15). + # Features This template comes with: diff --git a/docs/architecture/known-issues.md b/docs/architecture/known-issues.md new file mode 100644 index 0000000..3df0e71 --- /dev/null +++ b/docs/architecture/known-issues.md @@ -0,0 +1,91 @@ +# Known issues and technical debt + +This register lists defects and debt that were verified by reading the source (on 2026-09-22). It exists +so that people and agents **don't rediscover these repeatedly, and don't fix them silently inside unrelated +changes**. Fix each one in its own PR, then delete its entry or mark it resolved. Numbers are stable +because other docs reference them. + +## Correctness + +**#1 `ResultType.mapError` never calls its callback, so auth errors never reach the UI.** +`modules/common/lib/core/result_type.dart`: `mapError` returns `TError(e.error)` without invoking `error(...)`. +`AuthCubit.login` / `signUp` use `..mapSuccess(...)..mapError((f) => isError(f))`, so a `TError` leaves the +cubit in `RLoading` forever. It's latent today because the fake `AuthRepositoryImpl` always succeeds. It will +surface the moment a real backend is connected. Use `BaseCubit.onResult` or a `switch` until it's fixed. + +**#2 The two environment mechanisms disagree.** +- `Environment.envConfigFile` loads `env/.` (`--dart-define ENV`, default `dev`). `EnvConfig.apiUrl` reads + `API_URL_`. The committed `env/.dev` defines `API_URL` (unsuffixed), so **the base URL is `''`**. +- `FlavorConfig.getEnvFilePath()` and `EnvConfig.envConfigFile` both return `env/.env.example` and are unused. +- The doc comments tell you to create `env/.env` and add it to assets, but the code never loads `.env`. +- `melos run run:web` runs the **prod** entrypoint (`lib/main.dart`) with `env/.dev`, so the flavor is PROD with dev values. +- `app/pubspec.yaml` bundles all of `env/` as assets, so any secret placed there ships in the binary. +- `Environment.clientSecret` / `portalUrl` read unsuffixed keys and are unused. + +**#3 There are no tests, and CI fails before it checks anything.** +No package has a `test/` directory. `.github/workflows/sonar-qube-scann.yml` is enabled for PRs and pushes to `main`, +but its first step (`webfactory/ssh-agent`) fails because the `SSH_PRIVATE_KEY` secret isn't configured, so analyze, +tests and SonarQube are skipped. No pub dependency is git-based, so the step isn't needed. The workflow also has no +format-check or build step. `sonar.tests` lists `app/test` and `modules/domain/test`, and neither exists. + +**#4 The analytics scaffolding isn't wired.** +No `AnalyticsClient` is registered in GetIt, so any `TrackedPage` throws on first track. `routeObserver` isn't passed +to `GoRouter(observers:)`, so the enter/exit events never fire. `FirebaseAnalytics` throws `UnimplementedError`, and +`SetupAnalytics.initialize()` is never called. `firebase_core` is a dependency, but `Firebase.initializeApp` is commented out. + +**#5 Build targets point to files that don't exist.** +`ios/Flutter/Release.xcconfig` has `FLUTTER_TARGET=lib/main/env/main.dart`, but the prod entrypoint is `lib/main.dart`. +`ios/qa.xcconfig` uses `FLUTTER_TARGET=lib/main/env/main_dev.dart` and `PREFIX=dev`. The README build commands use +`-t lib/main/env/main.dart --dart-define-from-file=env_prod.json`, which is a nonexistent path and file. + +**#6 `addModule.py` copies from the wrong path.** +It clones `rootstrap/flutter-modules` into `/Users/Shared` (a hardcoded macOS path) but copies from +`/Users/Shared/flutter-base/modules/` and deletes `/Users/Shared/flutter-base`. The clone directory is `flutter-modules`. + +**#7 Error mapping is incomplete.** +`FailureMapper` maps `DioExceptionType.connectionError` to `UnexpectedFailure`, so `ConnectionFailure` is never produced +and `ConnectionErrorWidget` is never shown. In `FailureWidget`, the case `UnexpectedErrorWidget _` matches a `Failure` +against a widget type, which is dead code. No repository uses `toFailure()` yet. + +**#8 `AuthTokenInterceptor` clears every preference.** +It calls `Preferences.clear()` (which also removes theme, language, and cookie consent) on 401/403/422 and on +**every request made without a token**. It sets `Content-Type` only when a token exists. + +**#9 Minor code defects.** +`Images.appLogo` → `assets/icons/logo.png` doesn't exist (and `assets/icons/` isn't declared). `Images.img()` passes +`width` as `height`. `CustomNetworkImage` force-unwraps `svgIconColor!` when `color` is non-null. + +## Architecture and boundaries + +**#10 Boundary exceptions.** +`app/.../ui/custom/cookies.dart` resolves `CommonRepository` directly (it skips the cubit/service). `domain` depends on +`flutter_dotenv`, which is infrastructure. `common` depends on `dio` and `flutter_bloc`. The rule set is in +[modules.md](modules.md). None of it is tool-enforced. + +**#11 Service layer is pass-through.** `AuthService` only forwards to the repository, and there's no use-case layer. +That's acceptable, but it means "where business rules go" is by convention only (services). + +## Tooling and template hygiene + +**#12 Melos scripts.** +`pub:runner` runs `dart run build_runner` in every package, but only `app` declares `build_runner` and no generators are +configured, so it has nothing to do (and fails in packages without the dependency). `run:web` uses the prod entrypoint (see #2). + +**#13 Dependency hygiene.** +- `intl_utils` (a code generator) is a runtime dependency of `app` and `common`, and `flutter_gen` is an unversioned + `app` dev dependency that nothing uses. +- Test dependencies are declared but unused, because there are no tests (#3). + +**#14 Repository hygiene.** +There are empty `.github/instructions/*.md.new` files. A stray `ios/Podfile` sits at the repo root: a default Flutter-generated +Podfile that references a nonexistent `RunnerTests` target. The one the app uses is `app/ios/Podfile`. `app/linux/` contains only generated plugin registrant files (a partial platform). The template +`README.md`/`CHANGELOG.md` are still in each module. + +**#15 Documentation drift.** +The root `README.md` advertises "Chat with Gemini and Vertex AI" and an "RS-GPT-Review" GitHub Action. Neither exists in +this repo. It describes Bitrise CI, but there's no config for it, and its license badge points at `rootstrap/ios-base`. +`.cursor/rules/*.mdc` and `.github/instructions/*.instructions.md` prescribe conventions the code doesn't follow +(PascalCase `AuthService.dart` file names, `ALL_CAPS` constants, "avoid global cubits", entity classes with a +`parseFlexibleNumber` helper that doesn't exist). `CLAUDE.md` and `docs/` describe the actual code. + +**#16 Version sources disagree.** Pubspec `1.0.0+1`, iOS xcconfig `2.0.0`, and Android `build.properties` `1.0.0` (not read by Gradle). diff --git a/docs/architecture/modules.md b/docs/architecture/modules.md new file mode 100644 index 0000000..7486fa6 --- /dev/null +++ b/docs/architecture/modules.md @@ -0,0 +1,105 @@ +# Module guide + +A **module** is a local Dart/Flutter package under `modules//` with its own `pubspec.yaml`, +`analysis_options.yaml`, and `lib/`. It is consumed through a `path:` dependency. The repo is a **Dart pub +workspace**: the root `pubspec.yaml` lists every member under `workspace:`, each member sets +`resolution: workspace`, and one shared `pubspec.lock` sits at the root. Melos 7 reads the same workspace, with +its scripts under the root pubspec's `melos:` key. `melos bootstrap` resolves everything. + +## Packages + +| Package | pubspec `name` | Depends on (workspace) | Responsibility | Must NOT contain | +|---|---|---|---|---| +| `app/` | `app` | domain, data, common | UI, routing, theme, l10n, flavors, composition root (DI) | business rules, HTTP/storage calls | +| `modules/domain/` | `domain` | common | cubits and states, services, repository interfaces, models, env constants | Dio, shared_preferences, widgets, imports of `data` or `app` | +| `modules/data/` | `data` | domain, common | repository implementations, Dio setup and interceptors, `Preferences`, data sources | widgets, cubits, imports of `app` | +| `modules/common/` | `common` | none | result/failure types, platform and permission abstractions, analytics interface, validators, generic widgets (`ResponsiveBuilder`) | feature/product knowledge, imports of any workspace package | + +## Dependency rules + +```mermaid +flowchart TD + app --> domain + app --> data + app --> common + data --> domain + data --> common + domain --> common +``` + +**Allowed:** only the arrows above. These rules are **protected boundaries**. Changing them needs an +explicit architecture decision, not a drive-by edit. + +**Forbidden:** +- `common` → anything in the workspace. +- `domain` → `data` or `app` (domain owns the interfaces, and data implements them). +- `data` → `app`. +- `app` → `package:data/...` anywhere except `app/lib/main/init.dart` (it calls `DataInit.initialize`). + Presentation reaches data through domain interfaces resolved from GetIt. +- A cycle of any kind. Path deps would allow it at the pub level, so check it by review. + +**Current exceptions (debt, don't replicate):** +- `app/lib/presentation/ui/custom/cookies.dart` reads `CommonRepository` directly instead of going through a cubit or service. +- `domain` depends on `flutter_dotenv` (in `EnvConfig`), which is an infrastructure concern. +- `common` depends on `dio` (for `FailureMapper`) and `flutter_bloc` (for `CancelableCubitMixin`). + +None of this is enforced by tooling. The analyzer won't flag a forbidden import, because every +package can technically import any path dependency it declares. **Enforcement is the pubspec +dependency list plus review.** Adding a workspace path dependency to a pubspec is therefore an +architectural change. + +## Public API of a module + +There are no barrel files and no `src/` privacy. Consumers import files directly +(`package:domain/bloc/auth/auth_cubit.dart`). Conventions that approximate a public surface: + +- **`init.dart` + `XInit.initialize(GetIt)`** is the module's DI entrypoint. It's the only thing `app` needs + from `data`. +- A **leading underscore in a file name** (`_permission_manager_base.dart`, `_app_platform_impl.dart`) + marks an implementation file that should be reached through its `abstract/` interface and GetIt, not imported directly. +- **`abstract/` vs. `concrete/`** folders in `common` separate the interface from the platform implementation. +- Interfaces meant for other layers live in `domain/repositories/` and `domain/services/`. + +## How modules communicate + +- **At startup**, through GetIt: each module registers its implementations against interfaces. + Registration order is Common → Data → Domain. +- **At runtime**: widget → cubit (domain) → service (domain) → repository interface (domain) → implementation (data). +- **Data flows back** as `ResultType`, and cubits expose it as `Resource` state. +- **Between features**: through a shared service or by observing a global cubit. Cubits don't call each other. + +## Naming conventions (observed) + +- Files: `snake_case.dart`. Cubit `x_cubit.dart` + state `x_state.dart` in `domain/lib/bloc//`. +- Services: `domain/lib/services/_service.dart`, class `Service`. +- Repositories: interface `domain/lib/repositories/_repository.dart` (`Repository`), and implementation + `data/lib/repositories/_repository_impl.dart` (`RepositoryImpl`). +- Pages: `app/lib/presentation/ui/pages///_page.dart`, with sub-widgets beside them + (`login_form.dart`, `home_view.dart`). +- Enums for models provide `toName()` / `fromName()` (`AppLang`, `ThemeType`, `AuthStatus`). +- States: sealed class hierarchies (`AuthState`) or immutable classes with `copyWith` (`AppState`). + +## Creating a new module + +Use this when a capability is large, reusable, or independently versionable (for example, a payments SDK +wrapper or a chat feature). Most features should **not** become modules. They go into the existing layers. + +1. `cd modules && flutter create --template=package `. The existing modules were generated this way and + still carry the template README and CHANGELOG. Replace those. +2. In `modules//pubspec.yaml`: set `publish_to: none` and `resolution: workspace`, match the environment + constraints of the other members (`sdk: ">=3.6.0 <4.0.0"`, `flutter: ">=3.41.0"`), and add only the workspace + dependencies its layer allows (usually `common`, and `domain` if it implements domain interfaces). +3. Add `modules/` to the `workspace:` list in the root `pubspec.yaml`. Without it, `resolution: workspace` fails. +4. Copy `analysis_options.yaml` from a sibling module (`include: package:flutter_lints/flutter.yaml`) and add + `flutter_lints: ^5.0.0` to its dev dependencies. +5. Add `lib/init.dart` with `class Init { static Future initialize(GetIt getIt) async { … } }`. +6. Wire it: add a `path: ../modules/` dependency in `app/pubspec.yaml` and call `Init.initialize(getIt)` + in `app/lib/main/init.dart` at the right point in the order. +7. Add `modules//lib` to `sonar.sources` in `sonar-project.properties`. Otherwise `coverage/full_coverage.py` + warns and Sonar ignores it. Add `modules//test` to `sonar.tests` once tests exist. +8. `melos bootstrap`, then `melos run analyze` and `melos run format`. +9. Update the Packages table above and the dependency diagram. + +`addModule.py` is meant to import prebuilt modules from `rootstrap/flutter-modules` (`module/` branches), +but its copy path is wrong (known-issues #6). Do the copy by hand, following steps 2–9. Modules from that source +may predate the pub workspace, so add `resolution: workspace` and align their constraints. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..a809dad --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,202 @@ +# Architecture overview + +This describes the architecture **as implemented**. Deviations and defects are recorded in +[known-issues.md](known-issues.md). Module boundaries and dependency rules are in [modules.md](modules.md). + +## Style + +This is a layered architecture with dependency inversion, split across Dart packages: + +- **Presentation** (`app`): widgets, routing, theme, l10n, composition root. +- **Domain** (`modules/domain`): cubits and states, services, repository interfaces, models. +- **Data** (`modules/data`): repository implementations, networking, local storage. +- **Common** (`modules/common`): feature-agnostic primitives shared by all layers. + +It is *not* textbook Clean Architecture. There are no use-case classes (**services** play that role), +and **state management lives in `domain`**: cubits are domain classes, and `flutter_bloc` is a domain +dependency. Folders are organized by layer, not by feature. A feature is spread across the packages +by layer (see [feature-guide.md](../development/feature-guide.md)). + +```mermaid +flowchart LR + subgraph app["app (presentation + composition root)"] + W[Pages / Widgets] --> BP[BlocProvider / BlocBuilder] + R[go_router Routers] + INIT[main/init.dart] + end + subgraph domain["modules/domain"] + C[Cubits: BaseCubit<T>] --> S[Services] + S --> RI[[Repository interfaces]] + M[Models] + end + subgraph data["modules/data"] + RImpl[Repository impls] --> NET[Dio / NetworkConfig] + RImpl --> PREF[Preferences → SharedPreferences] + end + subgraph common["modules/common"] + RT[ResultType / Resource / Failure] + DEV[Platform, Permissions] + AN[AnalyticsClient] + end + BP --> C + RImpl -. implements .-> RI + INIT --> data & domain & common + domain --> common + data --> common +``` + +## Request/response data flow + +The canonical path, taken from the auth example: + +```mermaid +sequenceDiagram + participant UI as LoginForm (app) + participant Cu as AuthCubit (domain) + participant Sv as AuthService (domain) + participant Rp as AuthRepository → AuthRepositoryImpl (data) + UI->>Cu: context.read().login(email, pwd) + Cu->>Cu: isLoading() → emit RLoading + Cu->>Sv: logInWithCredentials() + Sv->>Rp: login() + Rp-->>Sv: Future> (TSuccess | TError) + Sv-->>Cu: ResultType + Cu->>Cu: emit RSuccess(AuthStateAuthenticated) / RError(exception) + Cu-->>UI: BlocBuilder rebuilds +``` + +Key types (all in `modules/common/lib/core/`): + +| Type | Where used | Shape | +|---|---|---| +| `ResultType` (sealed) | repository/service return values | `TSuccess(data)` or `TError(Exception?)` | +| `Resource` | cubit state (via `BaseCubit`) | `RLoading` / `RSuccess` / `RError`, and each keeps the last `data` | +| `Failure` (sealed, `implements Exception`) | the error payload in `TError` / `RError` | `ConnectionFailure`, `SocketTimeOutFailure`, `HttpFailure(code)`, `UnexpectedFailure` | +| `DioException.toFailure()` | inside data-layer repositories | maps Dio error types to `Failure` | + +`BaseCubit` (`domain/lib/bloc/base_cubit.dart`) provides `isLoading()`, `isSuccess(T)`, `isError(e)`, +and `onResult(ResultType)`. `ListBlocState` extends it for list screens. `CancelableCubitMixin` +(common) cancels in-flight futures on `close()`. + +> Use `onResult` or a `switch` on the sealed `ResultType`. The `mapSuccess`/`mapError` chaining in +> `AuthCubit` looks correct but `mapError` never invokes its callback (known-issues #1). + +## State ownership + +- **Global cubits** are registered as GetIt singletons in `DomainInit` and provided once in `App` + (`MultiBlocProvider`): + - `AppCubit`: theme and language (`AppState`, persisted through `CommonRepository`). + - `AuthCubit`: auth status (`BaseCubit`; sealed `AuthState`: Unknown/Authenticated/Unauthenticated/Error). +- **Screen cubits** (none exist yet; the intended pattern per `home_page.dart`'s TODO) should be created by a + `BlocProvider(create: (_) => XCubit(getIt()))` in the page widget, so they are disposed with the route. +- Cubits don't call each other. Coordination goes through services or widgets. + +## Dependency injection + +GetIt, with one instance: `getIt = GetIt.instance` in `app/lib/main/init.dart`. Startup +(`init()` → `initialize()`): + +1. `dotenv.load(fileName: Environment.envConfigFile)` +2. `CommonInit.initialize(getIt)`: `AppPlatform` (a conditional import selects the io or web impl), `PlatformInfo`, `PermissionManager` +3. `DataInit.initialize(getIt)`: `SharedPreferences`, `Preferences`, `AuthTokenInterceptor`, `Dio`, `EnvironmentService`, `AuthRepository`, `CommonRepository` +4. `DomainInit.initialize(getIt)`: `AuthService`, then the `AppCubit` and `AuthCubit` singletons +5. `runApp(App())` + +The order matters: Domain registers eager singletons that resolve the repositories from Data. +`common/lib/init.dart` also keeps its own top-level `late GetIt getIt` (set in `CommonInit`), which +common widgets like `ResponsiveBuilder` use. + +Conventions: register an interface type (`registerLazySingleton(() => AuthRepositoryImpl(getIt()))`). +Widgets resolve with `getIt()` only for global objects. Prefer `context.read()` for cubits in the tree. + +## Navigation + +`app/lib/presentation/navigation/routers.dart`: + +- The `Routes` enum is the single list of route names. `path` = `/`, `subPath` = `` (for nested + routes), and `Routes.x.go(context)` navigates by name. +- The tree: `/` (it renders `SplashPage` wrapped in a `BlocListener` that redirects on auth + changes) → two `ShellRoute`s (each wraps children in `DebugBanner` when `kDebugMode`): + - **Unauthenticated:** `/onboarding`, `/auth` (login) → `/auth/signup`. + - **Authenticated:** `/app` (home) → `/app/placeholder`. +- **Guards:** a per-route `redirect` checks `getIt().isLoggedIn()`. +- **Deep links:** `App._initRouter` reads the initial location from `Uri.base` (web) or + `AppLinks().getInitialLink()` (mobile) before building the router. The web router uses path URLs + (`usePathUrlStrategy()`). + +To add a screen: add an enum value, add a `GoRoute` under the correct shell, then navigate with `Routes.x.go(context)`. + +## Networking + +`modules/data/lib/network/`: + +- `NetworkConfig.provideDio(AuthTokenInterceptor?)` sets `baseUrl: EnvConfig.apiUrl` and timeouts from + `NetworkConstants` (2 s connect / 2 s receive). In debug it adds a `LogInterceptor`. +- `AuthTokenInterceptor` adds `token: ` + `Content-Type: application/json` when a token is + stored, and **clears all preferences** on 401/403/422 or when no token is present. +- `EnvironmentServiceImpl.setEnvironment(env)` switches `EnvConfig.env` at runtime and updates the Dio base + URL. The `EnvironmentSelector` widget uses it. +- `data_sources/remote` and `data_sources/local` hold only READMEs. **No repository calls Dio yet.** + `AuthRepositoryImpl` is a fake that sleeps for 1 s and stores `'new-token'`. + +## Persistence + +`Preferences` (abstract) and `PreferencesImpl` (shared_preferences) in `modules/data/lib/preferences/` store +the token, language, theme, and cookie consent. Only data-layer code touches `Preferences`. Domain and app +go through `CommonRepository` / `AuthRepository`. There is no database, secure storage, or cache layer. + +## Environments and flavors + +There are two mechanisms, and they don't fully agree. Understand both before changing either. + +| Piece | File | What it does | +|---|---|---| +| Entrypoints | `app/lib/main.dart` (prod), `app/lib/main/env/main_dev.dart`, `main_qa.dart` | Construct `FlavorConfig(flavor: …)`, which sets `EnvConfig.env` to `DEV`/`QA`/`PROD` | +| dotenv file choice | `Environment.envConfigFile` in `app/lib/main/env/env_config.dart` | Loads `env/.`, where `ENV` is the `--dart-define` `ENV` value (default `dev`) | +| API URL lookup | `EnvConfig.apiUrl` in `modules/domain/lib/env/env_config.dart` | Reads `dotenv.env['API_URL_']` | +| Bundled files | `app/pubspec.yaml` assets: `env/` | Everything in `app/env/` ships inside the app bundle | + +The committed `env/.dev` defines `API_URL` (no suffix) and `ENV=dev`, but `EnvConfig.apiUrl` looks for +`API_URL_DEV`, so **the Dio base URL is empty with the shipped files**. `env/.env.example` shows the +suffixed format. See known-issues #2 for the full list of drift (unused `getEnvFilePath`, the `.env` +naming in comments vs. `.dev` on disk, and the fact that bundled env files are readable by anyone with the binary). + +Platform flavor support: +- **iOS:** build configurations `Debug/Release/Profile` × (`dev`, `qa`, and default), and schemes `Dev`, `QA`, + `Runner`. `ios/dev.xcconfig` / `ios/qa.xcconfig` / `ios/Flutter/*.xcconfig` set `FLUTTER_APP_ID`, + `FLUTTER_APP_NAME`, and `FLUTTER_TARGET`. Bundle ID = `com.rs.$(FLUTTER_APP_ID)` (+ `.debug.dev` / `.debug.qa`). +- **Android:** no product flavors. `applicationId = "com.rs." + flutter.appId` from `android/build.properties`, + and the debug build type adds `.debug`. Environment selection is by `-t` entrypoint only. + +## Error handling + +- The data layer should catch `DioException` and return `TError(e.toFailure())`. Nothing does yet, because + there are no real remote calls. +- Cubits turn errors into `RError(exception)`. Widgets branch on `state is RError`. +- `FailureWidget(failure:, onRetry:)` (app `ui/custom/`) renders connection and unexpected errors with retry. +- There is no global error handler, crash reporter, or structured logger. `debugPrint` and `dart:developer` `log` are used. + +## Localization + +`intl` + `intl_utils` (the `flutter_intl` block in `app/pubspec.yaml`). ARB sources are +`app/lib/presentation/resources/locale/intl_en.arb` / `intl_es.arb`, generated into `locale/generated/` as class `S`. +Use `S.of(context).key`. The supported locales and the `AppLang` ↔ `Locale` map are in +`app/lib/presentation/utils/lang_extensions.dart`. The domain enum `AppLang` must stay in sync with that map. + +## Theming + +Material 3 with tonal palettes: `ThemeColors` (abstract) is implemented by `LightThemeColors` and +`DarkThemeColors`. Each is a `MaterialColor` with tones 0–100, and `AppThemeData.colorScheme` maps +tones to Material roles. `LocalTheme` builds `ThemeData` and the text styles (Roboto families bundled in +`app/fonts/`). Access them with `Theme.of(context)`, `context.colors.primary.v40`, or `context.localTheme` +(extension in `app_themes.dart`). The theme type is persisted by `AppCubit`. + +## Analytics and platform + +- `AnalyticsClient` (common) is the interface. `FirebaseAnalytics` is a stub that throws `UnimplementedError`. + `TrackedPage` (app `ui/base/`) auto-tracks lifecycle events, **but no `AnalyticsClient` is registered and the + `routeObserver` isn't attached to GoRouter** (known-issues #4). Treat analytics as an unwired extension point. +- `firebase_core` is a dependency, and `Firebase.initializeApp` is commented out in all three entrypoints. +- `AppPlatform` / `PlatformInfo` / `PermissionManager` (common/devices) abstract platform checks and + camera/gallery/notification permissions. Web and io implementations are chosen by conditional imports in + `common/lib/init.dart`. diff --git a/docs/development/bootstrap-customization.md b/docs/development/bootstrap-customization.md new file mode 100644 index 0000000..174213a --- /dev/null +++ b/docs/development/bootstrap-customization.md @@ -0,0 +1,85 @@ +# Bootstrap customization guide + +What to keep, what to change, and what to delete when starting a new project from this template. +This complements the step-by-step setup in the root `README.md`. + +## What each part of the template is + +| Category | What | Guidance | +|---|---|---| +| **Base infrastructure** (keep) | Package layout and dependency direction; `XInit` DI pattern; `ResultType`/`Resource`/`Failure`; `BaseCubit`, `ListBlocState`, `CancelableCubitMixin`; `NetworkConfig` + `AuthTokenInterceptor`; `Preferences`; `AppCubit` (theme/lang); `Routes` enum + go_router shells; `LocalTheme`/`ThemeColors` scaffolding; intl setup; platform/permission abstractions; the pub workspace + Melos scripts (root `pubspec.yaml`); `.fvmrc`; `coverage/full_coverage.py` | Preserve these. Change them only through an architecture decision, and upstream improvements to the template. | +| **Extension points** (customize) | `ThemeColors` palettes (`light_theme_colors.dart`, `dark_theme_colors.dart`); `LocalTheme` text styles + fonts; `Dimen`; `Images` enum; `.arb` files; `AnalyticsClient` implementation; `PermissionManager` methods; `Preferences` keys; `NetworkConstants`; `AuthTokenInterceptor` header/clear policy; `FlavorValues` (empty, for per-flavor values); `TrackedPage` | Designed to be filled in per project. | +| **Example / reference** (replace) | Auth flow (`AuthRepositoryImpl` is a fake that sleeps and stores `'new-token'`), login/sign-up pages, onboarding (4 placeholder pages), home, splash, `/app/placeholder` route, `User` model, cookies banner, `EnvironmentSelector`, `DebugBanner` | They show the patterns. Rewrite them against your real backend and design, keeping the same shape. | +| **Placeholders** (must replace) | See the checklist below | Leaving any of these in production is a defect. | +| **Generated** (never hand-edit) | `app/lib/presentation/resources/locale/generated/**`; Flutter plugin registrants; `ios/Flutter/Generated.xcconfig` | Regenerate them instead. | +| **Protected boundaries** | Dependency rules in [modules.md](../architecture/modules.md#dependency-rules); interfaces in domain / impls in data; Common → Data → Domain init order | Changing these needs an explicit decision. | + +## New-project checklist + +### Identity +- [ ] `android/build.properties`: `flutter.appId`, versions, SDK levels. The applicationId becomes `com.rs.`. + Change the `"com.rs."` prefix in `android/app/build.gradle` if you aren't using Rootstrap's namespace. +- [ ] Android `namespace` and Kotlin package `com.rootstrap.base.flutter_base_rootstrap` (`android/app/build.gradle`, + `AndroidManifest.xml` `package`, `src/main/kotlin/.../MainActivity.kt`). +- [ ] `android:label="flutter_base_rootstrap"` in `AndroidManifest.xml`. +- [ ] iOS `FLUTTER_APP_ID` / `FLUTTER_APP_NAME` in `ios/Flutter/Debug.xcconfig`, `ios/Flutter/Release.xcconfig`, + `ios/dev.xcconfig`, and `ios/qa.xcconfig` ("RS Base …"). The bundle ID becomes `com.rs.$(FLUTTER_APP_ID)`, and the `com.rs.` prefix is + in `project.pbxproj` `PRODUCT_BUNDLE_IDENTIFIER`. +- [ ] `CFBundleName` `flutter_base_rootstrap` in `ios/Runner/Info.plist`. +- [ ] Web: `` / `apple-mobile-web-app-title` in `web/index.html`, and `web/manifest.json` name, description, and colors. +- [ ] `appName` ("Flutter Target") in the `.arb` files. +- [ ] Package descriptions ("A new Flutter project." / "A new Flutter package project.") and the template `README.md` / + `CHANGELOG.md` in each `modules/*`. +- [ ] Root `pubspec.yaml` `name: flutter_base_workspace` / `description`, if you want the workspace named after the project. +- [ ] License: `app/LICENSE.md`, `modules/*/LICENSE` (MIT). Replace or remove them for private projects (the README says so). + +### Environments +- [ ] Decide on one env scheme and fix the drift described in [overview.md § Environments](../architecture/overview.md#environments-and-flavors) + and known-issues #2. Today the committed `env/.dev` doesn't provide the `API_URL_DEV` key that `EnvConfig.apiUrl` reads. +- [ ] Create env files per flavor, and don't commit real secrets. Everything in `app/env/` is bundled into the app as an asset. + `SECRET_KEY` in `env/.dev` is a placeholder. +- [ ] Fix the flavor targets: `ios/qa.xcconfig` points `FLUTTER_TARGET` at `main_dev.dart` (with `PREFIX=dev`), and + `ios/Flutter/Release.xcconfig` points at the non-existent `lib/main/env/main.dart`. +- [ ] Android product flavors (if you need them). None exist today. +- [ ] `NetworkConstants`: timeouts (2 s is aggressive), `tokenHeader` (`"token"`), and the example `productsPath` / `baseUrl`. + +### Branding and design +- [ ] Palettes in `themes/resources/*_theme_colors.dart` (currently the Material 3 baseline purple), and `borderRadius` in `app_themes.dart`. +- [ ] Fonts (`app/fonts/`, `pubspec.yaml` `fonts:`, and the family names in `LocalTheme`). +- [ ] App icons (`android/app/src/main/res/mipmap-*`, `ios/Runner/Assets.xcassets/AppIcon.appiconset`), launch screens, and `web/icons`. +- [ ] `Images.appLogo` points to `assets/icons/logo.png`, which doesn't exist. Add the asset and a `pubspec.yaml` entry. + +### Integrations +- [ ] Firebase: uncomment and fill in `Firebase.initializeApp` in `main.dart`, `main_dev.dart`, and `main_qa.dart`, and add the platform config files + (`google-services.json` / `GoogleService-Info.plist`, which are git-ignored in `app/.gitignore`). +- [ ] Analytics: implement `AnalyticsClient` (for example, finish `FirebaseAnalytics`), register it in GetIt, call + `SetupAnalytics.initialize()`, and add `routeObserver` to `GoRouter(observers: [...])`. +- [ ] Auth: replace `AuthRepositoryImpl` with real API calls, and check `AuthTokenInterceptor`'s "clear everything on + 401/403/422" policy. +- [ ] Deep links: `app_links` is wired for the initial link, but there's no Android intent filter or iOS associated + domains config. Add them per platform. + +### Signing and release +- [ ] Android: create a keystore and `android/key.properties` (git-ignored). `build.gradle` reads `storeFile`, `storePassword`, + `keyAlias`, and `keyPassword`. +- [ ] iOS: signing team and provisioning profiles in Xcode. None are committed, and the README marks this as TODO. +- [ ] Versioning: `version:` in `app/pubspec.yaml` (1.0.0+1), which Flutter writes into `android/local.properties`, and that's what + `build.gradle` reads. The `flutter.versionName/Code` in `build.properties` are **not** read by Gradle. The iOS xcconfigs + also set `FLUTTER_BUILD_NAME/NUMBER` (2.0.0). These disagree today. Pick one source. + +### CI/CD and quality +- [ ] `.github/workflows/sonar-qube-scann.yml` runs on every PR and push to `main` (analyze, tests, coverage + SonarQube), + using the Flutter version from `.fvmrc`. Set the secrets `SONAR_TOKEN` and `SONAR_URL`. It also requires + `SSH_PRIVATE_KEY`, which is only needed for git-based pub dependencies (there are none), so either set it or drop the + `ssh-agent` step. Without it, the job fails at that step (known-issues #3). +- [ ] Bump the Flutter version with `fvm use <version>` and commit `.fvmrc`. CI follows it. +- [ ] `sonar-project.properties`: `projectKey`, `projectName`, `host.url`. `sonar.tests` lists `modules/domain/test`, + which doesn't exist yet. +- [ ] Add `melos run format` and a `flutter build` to CI. The current workflow runs neither (the README also mentions Bitrise + and an RS-GPT-Review action, but neither is configured in this repo). +- [ ] `.github/pull_request_template.md`: adjust the issue-tracker link. + +### Clean-up of examples +- [ ] Remove or replace the example pages and strings you don't need (onboarding copy, "Sorry we didn't find any product", terms hint). +- [ ] Remove the `/app/placeholder` route. +- [ ] Remove the unused dependencies you don't adopt (see known-issues #13). diff --git a/docs/development/feature-guide.md b/docs/development/feature-guide.md new file mode 100644 index 0000000..0156203 --- /dev/null +++ b/docs/development/feature-guide.md @@ -0,0 +1,98 @@ +# Feature development guide + +How to implement a typical feature (for example, "list products from the API and show details") using the +established patterns. The canonical reference is the **auth** feature. Mirror it. + +## Flow + +``` +requirement + → decide placement: existing layers (default) or a new module (see modules.md) + → domain: model(s) → repository interface → service → cubit + state + → data: DTO/parsing → repository impl (Dio / Preferences) → register in DataInit + → domain: register service + (global) cubit in DomainInit + → app: route (Routes enum + GoRoute) → page + widgets → BlocProvider/BlocBuilder → strings in .arb + → tests: cubit, repository, widget +``` + +## 1. Domain (`modules/domain/lib/`) + +- **Model**: `models/<name>.dart`. It's an immutable class or enum, with no JSON. (`User`, `AppLang` are examples.) +- **Repository interface**: `repositories/<name>_repository.dart`, where each method returns `Future<ResultType<T>>`. + ```dart + abstract class ProductRepository { + Future<ResultType<List<Product>>> getProducts(); + } + ``` +- **Service**: `services/<name>_service.dart`. It orchestrates one or more repositories. It's thin today + (`AuthService` just forwards), so put cross-repository and business rules here, not in cubits. +- **Cubit + state**: `bloc/<feature>/<feature>_cubit.dart` and `_state.dart`. + - For async load/submit screens, extend `BaseCubit<T>` so the state is `Resource<T>`: + ```dart + class ProductsCubit extends BaseCubit<List<Product>> { + final ProductService _service; + ProductsCubit(this._service) : super(RSuccess(data: const [])); + Future<void> load() async { + isLoading(); + onResult(await _service.getProducts()); + } + } + ``` + - For lists with local add/remove, extend `ListBlocState<T>`. + - Mix in `CancelableCubitMixin` and wrap futures with `toCancelable(...)` when a request may outlive the screen. + - For multi-variant state (like `AuthState`), use a sealed class and emit it as the `T` of `Resource<T>`. + - Don't chain `mapSuccess`/`mapError` for side effects (known-issues #1). + +## 2. Data (`modules/data/lib/`) + +- **Remote calls**: inject the registered `Dio` (`getIt<Dio>()`) into the repository or a data source under + `data_sources/remote/`. Add path constants to `network/config/network_constants.dart`. +- **Parsing**: there's no codegen. Write `fromJson` by hand in a data-layer class and map it to the domain model + before returning. Domain models must not know JSON. +- **Errors**: wrap calls so every failure becomes a `Failure`: + ```dart + try { + final res = await _dio.get(NetworkConstants.productsPath); + return TSuccess((res.data['products'] as List).map(ProductDto.fromJson).map((d) => d.toModel()).toList()); + } on DioException catch (e) { + return TError(e.toFailure()); + } + ``` +- **Local data**: extend the `Preferences` interface and impl for small key/value data. Anything larger needs a + decision (no DB is set up). +- **Register**: `getIt.registerLazySingleton<ProductRepository>(() => ProductRepositoryImpl(getIt()));` in `DataInit`. + +## 3. Register domain objects (`modules/domain/lib/init.dart`) + +- Services: `getIt.registerLazySingleton(() => ProductService(getIt()));` +- A cubit is registered **only if it's global** (it lives for the whole app, like `AppCubit`/`AuthCubit`). Screen cubits + aren't registered. They're created in the page's `BlocProvider`. + +## 4. Presentation (`app/lib/presentation/`) + +- **Route**: add a value to the `Routes` enum and a `GoRoute` under the right `ShellRoute` in `navigation/routers.dart`. + Authenticated screens go under `/app`, and public ones go under the first shell. Use `subPath` for nested routes. +- **Page**: `ui/pages/<area>/<feature>/<feature>_page.dart`. It provides the cubit, and a sibling `_view`/`_form` widget renders it: + ```dart + BlocProvider(create: (_) => ProductsCubit(getIt())..load(), child: const ProductsView()) + ``` +- **Render state**: `BlocBuilder<ProductsCubit, Resource<List<Product>>>` and branch on `RLoading` / `RError` / `RSuccess`. + Use `FailureWidget(failure: state.exception as Failure?, onRetry: …)` for errors, `PrimaryButton(isLoading: …)` for + submits, and `FormValidator` for inputs. +- **Strings**: add keys to **both** `intl_en.arb` and `intl_es.arb`, then run `cd app && dart run intl_utils:generate` and use `S.of(context).key`. +- **Styling**: `Dimen.*` for spacing and sizes, `Theme.of(context).textTheme/colorScheme` or `context.colors`. Don't hardcode values. +- **Analytics (optional)**: extend `TrackedPage` only after `AnalyticsClient` is registered and `routeObserver` is added + to GoRouter's `observers` (known-issues #4). + +## 5. Tests + +See [testing.md](testing.md). At minimum: a cubit test (states emitted for success and failure), a repository test +(DTO mapping + `DioException` → `Failure`), and a widget test for the page's loading, error, and success branches. + +## Checklist for a reviewer + +- [ ] No forbidden imports ([modules.md](../architecture/modules.md#dependency-rules)). +- [ ] The repository returns `ResultType`, and the cubit exposes `Resource`. +- [ ] Registrations are in the right `init.dart`. Screen cubits aren't registered as singletons. +- [ ] Strings are localized in all `.arb` files, and the generated code is refreshed. +- [ ] Tests are added. `melos run analyze` and `melos run format` are clean. diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 0000000..fce838d --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,59 @@ +# Testing guide + +## Current state (verified) + +- **There are no tests.** No package has a `test/` directory. +- There are no integration tests (`integration_test/`), no golden tests, and no mocks or fakes checked in. +- The test dependencies are declared but unused: + - `app`: `flutter_test`, `bloc_test`, `mocktail`, `build_runner` + - `domain`, `data`: `flutter_test`, `mocktail` + - `common`: `flutter_test` only +- There's no coverage threshold. Coverage is collected and uploaded to SonarQube by `coverage/full_coverage.py`. +- CI (`.github/workflows/sonar-qube-scann.yml`) runs the command in the first row below, but it currently fails before + reaching the tests (known-issues #3). + +## Commands + +| What | Command | +|---|---| +| All packages (what CI runs) | `melos exec --dir-exists=test -- flutter test` (from the repo root) | +| One package | `cd <pkg> && flutter test` | +| One file | `flutter test test/path/to/file_test.dart` (from the package dir) | +| Coverage for one package | `flutter test --coverage` → `<pkg>/coverage/lcov.info` | +| Merged coverage + Sonar | `python3 coverage/full_coverage.py` (interactive), `--ci`, or `--dry-run` | + +Use `fvm flutter` if `flutter` isn't aliased to the pinned SDK. A package's tests only run once it has a `test/` +directory: `melos exec --dir-exists=test` and `full_coverage.py` (which iterates the packages listed in +`sonar.sources`) both skip packages without one. + +## Expected conventions for new tests + +These follow from the declared dev dependencies and the architecture. Keep new suites consistent with them. + +- **Location**: mirror `lib/` under the owning package's `test/`, for example + `modules/domain/test/bloc/auth/auth_cubit_test.dart` for `modules/domain/lib/bloc/auth/auth_cubit.dart`. File suffix `_test.dart`. +- **Cubits (domain)**: use `bloc_test`'s `blocTest` with a `mocktail` mock of the service. Assert the `Resource` sequence + (`RLoading` → `RSuccess`/`RError`). `bloc_test` is currently only an `app` dev dependency, so add it to + `modules/domain/pubspec.yaml` `dev_dependencies` (then `melos bootstrap`) when you write the first domain cubit test. +- **Services**: plain `test` with a mocked repository interface. +- **Repositories (data)**: mock `Dio` / `Preferences` with `mocktail`. Cover DTO → model + mapping and `DioException` → `Failure` for each error type you handle. +- **Common utilities**: pure unit tests (`FormValidator`, `ResultType`, `FailureMapper`, `Resource`). These are the cheapest + high-value tests in the repo. +- **Widgets (app)**: `testWidgets`, providing cubits with `BlocProvider.value` over a `MockCubit` (from `bloc_test`). + Localized widgets need `localizationsDelegates: LangExtensions.appLocalizationDelegates` on the test `MaterialApp`. + Anything that reads `getIt` needs registrations in `setUp`. Call `GetIt.instance.reset()` in `tearDown`. +- **Mocks**: use `mocktail` (no codegen). It's the only mocking library declared. Don't add `mockito`, which needs + build_runner and produces generated `*.mocks.dart` files. +- When you add a package's first tests, make sure `<pkg>/test` is in `sonar.tests` in `sonar-project.properties`. It + currently lists `app/test` and `modules/domain/test`, which don't exist yet. + +## What a change must test + +| Change | Minimum tests | +|---|---| +| New/changed cubit | `blocTest` for each public method: success and error paths | +| New/changed repository impl | mapping + each failure path | +| New/changed common utility | unit tests covering its branches | +| New page / significant widget | widget test for the loading, error, and success renderings | +| Bug fix | a regression test that fails without the fix |